Local Home Assistant integration for Tuya EV chargers over LAN, using tinytuya.
No cloud is required to read or control the charger.
Integration Home Assistant locale pour borne Tuya en LAN via tinytuya.
Repository: https://github.com/lachand/EV_charger Author: Valentin Lachand Pascal (GitHub: @lachand)
Reference charger: de-portable-ev-charger-3-5kw-v2. Also reported working on
the dé Mobile Wallbox 11 kW 3-phase and other dé/Tuya models — see
Compatibility.
- Add this repository in HACS (
Integrations→Custom repositories→ categoryIntegration). - Install Tuya EV Charger Local, then restart Home Assistant.
- Add the integration from
Settings→Devices & Services→Add integration.
The setup flow offers three ways to get your charger's credentials. Pick one.
Listens for Tuya UDP broadcasts and lists the devices it finds, pre-filling the
IP, device ID and protocol version. You then paste the local_key.
Requires Home Assistant to be on the same subnet as the charger — UDP broadcasts do not cross VLANs or most Wi-Fi repeaters in NAT mode.
Enter your Tuya IoT Access ID and Access Secret, pick your charger from
the list, and both device_id and local_key are filled in automatically. The
IP is still resolved locally.
To get those credentials:
- Create a free account on https://iot.tuya.com.
- Create a Cloud project (development method Smart Home, data centre = your region, e.g. Central Europe). Note the Access ID and Access Secret.
- In Devices → Link Tuya App Account, add your app account by scanning the QR code from the Smart Life app (Me → scan icon).
If you keep these credentials configured, the integration can also re-download
the local_key on its own when it changes — see
Automatic recovery.
Tuya IoT trial projects expire (1 month, extendable to ~6). After expiry the cloud lookup stops working; local control is unaffected.
Classic path if you already have host, device_id and local_key, for example
from python -m tinytuya wizard, which writes them to devices.json.
local_key is a secret, and it changes if you re-pair or reset the charger.
Two things routinely break a local Tuya setup. The integration now heals from both without any manual reconfiguration.
The IP changes (new DHCP lease after a power cycle). The charger is located
again by its device_id, which is stable across power cycles, from its UDP
broadcast. If the stored device_id is itself stale, candidates are probed with
your local_key and the one that answers a real status read is adopted.
Recovery happens in memory, so no reload; the new IP is persisted at the next
startup.
The local_key changes (after re-pairing). When the control port answers but
payloads no longer decrypt, the key is re-downloaded from the Tuya Cloud and
persisted — only if you configured cloud credentials.
Home Assistant DHCP discovery is also declared, so a new lease can update the IP immediately when the charger's MAC is known.
Still, the most robust fix is a DHCP reservation on your router. Automatic recovery is a safety net, not a substitute.
Chargers advertise a short list of currents in DP 107, e.g. [6, 8, 10, 13, 16].
That is only the set of shortcuts the Tuya app displays — not a hardware
limit. Verified by writing 11 A to a charger that advertises the list above and
reading it back successfully.
So number.charge_current accepts any value in 1 A steps, from the charger's
minimum up to its own maximum (DP 152). Turn off the continuous_current option
if your charger really is restricted to the advertised steps.
Charge the car from solar surplus only.
Surplus mode needs one thing: a sensor reporting your grid power, not your solar production.
- Open the integration's Configure dialog.
- Set Grid power sensor to any
sensor.*entity whose state is a number in watts. Every sensor entity is selectable, so if yours does not show up, check that its state is numeric and that it is notunavailable. - Sign convention: positive when importing from the grid, negative when exporting. If yours is the other way round, tick Invert grid sensor sign rather than creating a template sensor.
- Set start and stop thresholds (W). Charging starts once the available surplus stays above the start threshold, and stops below the stop threshold. The stop threshold is clamped to never exceed the start threshold.
- Turn on
switch.surplus_mode.
To check it is working, watch sensor.surplus_raw_w (what the integration reads
from your sensor, after any inversion) and sensor.surplus_last_decision_reason,
which says in plain, translated language why it started, stopped, or did nothing
— "Waiting for off-peak hours", "Reduced to stay under the inverter output
limit", "About to start — waiting to confirm the surplus holds".
That sensor's attributes carry the reasoning: which decision gate decided,
which ones declined before it, and the figures they weighed. If you want the same
answer on demand without waiting for a poll, call
dry_run_surplus.
Everything else — battery thresholds, forecast, curtailment — is optional.
classicvszero_injectionis auto-detected: a curtailed-power sensor selectszero_injection, otherwiseclassic.- Battery hysteresis: above the high threshold the battery may contribute, below the low threshold it may not.
- Optional battery net-discharge guard: discharge above the configured budget is subtracted from the available surplus, downshifting rather than stopping when possible.
- Forecast is optional and used only as an anti-drop guard, to avoid stopping on a short cloud transient.
- Quick profiles via
select.surplus_profile:eco— conservative, avoids battery dischargebalanced— defaultfast— reacts sooner, starts on less surplus
- The rest (line voltage, ramp, delays, cooldowns, protections) is fixed internally.
A charger's rating is not its circuit's rating: a 32 A unit is often wired to a smaller breaker, and many wiring rules require the circuit to be sized above the charging current (Spain's ITC-BT-52 asks for 125 %, making 20 A the real ceiling on a 25 A breaker).
Set Maximum charging current to what your installation can carry (0 uses
the charger's own limit). It caps number.charge_current and everything
surplus regulation and load balancing may write — they all draw from the same
list, so nothing can widen it afterwards.
Minimum charging current raises the floor, for cars that refuse to start below 8 A.
Unlike load balancing below, this needs no grid sensor: it is a fixed property of your wiring, not a live measurement.
Set Maximum house power to your subscribed power in watts (0 disables it).
Using the same grid sensor as surplus mode, the integration keeps the whole
house — car included — under that limit: it caps the charging current, and stops
charging outright if even the minimum current would not fit.
This is a safety limit, not a surplus feature: it applies whether or not surplus mode is on, and nothing raises the current above the cap afterwards. With no grid sensor, or when its reading is unavailable, no cap is applied — capping on a stale measurement would be worse than not capping at all.
Typical case: a 6 kVA subscription with the oven, the hob and the car all on.
For hybrid inverters with the house wired to the inverter's backup (Load) output, so that everything the house consumes passes through the inverter.
Set Maximum inverter output to a little under the inverter's rating (e.g.
5500 for a 6 kW inverter) and point Total household load sensor at a sensor
reporting the whole house consumption. The car is capped so that total load
stays under the inverter rating, stopping the session if even the minimum
current will not fit.
The difference from load balancing is the measurement point, and it is the whole point. Load balancing reads the grid meter. That does not work here: when the house suddenly draws more — a hob, an oven — a hybrid inverter covers it from the battery, so the grid meter stays near zero while the inverter is being overloaded. Only a total-load reading sees it. So this limit reads total load, not the grid.
Two things worth being clear about:
- It reduces the risk, it cannot guarantee no trip. A hob is a ~2 kW step in
under a second. The integration reacts as soon as your load sensor updates —
which is fast for a local Modbus sensor, slow for a cloud one — but a cap can
only ever be as quick as its input. Leave a margin (the
5500-for-6000above), and keep Maximum charging current set as a static backstop. - Like load balancing, it does nothing without a working sensor: a cap from a missing or stale total-load reading is worse than none.
Both protection limits can be set at once; the tighter of the two applies — and
they also bound force_charge_for, since forcing a charge overrides scheduling,
not the physical limits of the installation.
The caveat above — a cap is only as fast as its sensor — has an answer for the loads Home Assistant knows about in advance. Set Entities announcing a large load to the switches that turn on before the draw appears:
switch.hob: 3000, switch.oven: 2500
When one of those turns on, its wattage is held back from the car immediately, without waiting for the power sensor. The integration also subscribes to those entities, so it reacts on the state change rather than at the next poll.
A reservation is a bridge over the sensor's latency, not a standing allowance: it expires after two minutes, by which time the appliance is in the measurement. Without that expiry the same 3 kW would be subtracted twice and the car would stay throttled for as long as the hob was on.
The charger accepts one local connection, so the poll interval follows what is happening rather than staying fixed: faster while a charge is being regulated, slower when a cable is merely plugged in, slower still when the charger is asleep. Your configured interval sets the scale; only its shape is adjusted.
Two optional settings, both empty by default and both inert until filled in:
| Option | Example | Meaning |
|---|---|---|
| Off-peak windows | 22:00-06:00, 12:30-14:30 |
Only charge during these hours |
| Departure time | 07:00 |
Be ready by then… |
| Energy needed by departure | 20 |
…with this many kWh delivered |
With windows configured, charging waits for off-peak. The deadline overrides that: once waiting any longer would miss the departure — the remaining energy at the current charging power, plus a 20-minute safety margin — the charge starts immediately, off-peak or not. A departure time without an energy target does nothing, since there is no way to know how long the charge takes.
Malformed windows are ignored rather than fatal, so a typo narrows the schedule instead of breaking the integration.
Some tariffs cannot be written as a fixed clock range — a schedule that depends
on weekday vs. weekend vs. public holiday, for instance. For those, point
Off-peak sensor at a binary_sensor.* or input_boolean.* entity that
already reports "off-peak now" (tick Invert if on means peak instead).
Once set, the sensor is authoritative: Off-peak windows stops driving the charge/wait decision, even if both are configured. Windows remain useful as a fallback for session cost-splitting (below) and can stay filled in.
A missing or unavailable reading fails open — charging is treated as unrestricted, the same as leaving the feature unconfigured — rather than falling back to the windows text. That fallback is deliberate: once you have pointed this at a sensor, that sensor is the one thing this feature consults, so "why is it (not) charging" always has one answer instead of depending on which of two sources happened to be reachable.
Off-peak/peak session costing (see Session history and cost, below) has no history to replay for a live sensor the way it does for windows, so it is tallied live instead, sampled while a session the integration itself started is charging. A session with nothing tracked — none started before this was configured, one that predates a Home Assistant restart, or one started outside the integration (e.g. from the Tuya app) — falls back to reconstructing the split from Off-peak windows, exactly as before this option existed.
The time needed is estimated from your car's own charge curve, learned from past sessions, not from the charger's rating. A vehicle limited to 3.7 kW on a 7.4 kW charger would otherwise have its charging time halved and be started hours too late to meet the deadline. Learning kicks in after three usable sessions, is tracked per vehicle, and may only ever lengthen the plan — a stray record can never shorten it and put the departure at risk. While a charge is running the live measurement is used instead, since nothing beats it.
Because the instantaneous power is recorded against how much has already been
delivered, the curve captures the taper — the way the last kWh come in slower
than the first as the battery fills. A departure that needs a lot of energy is
then planned honestly, allowing for that slow tail rather than assuming a flat
rate to the end. Each vehicle's curve is exposed as a diagnostic sensor
(sensor.<car>_charge_curve, disabled by default) with the full shape in its
attributes, so it can be plotted.
With surplus mode on, regulation chains three tiers: solar first; then, if a battery SOC sensor and thresholds are configured (see Surplus mode → battery, above), the house battery down to its low threshold; then, if an off-peak window is configured here, a grid-only charge restricted to that window instead of stopping outright once the battery floor is hit. It starts the moment the window opens (or immediately, if the floor is already hit while inside it), still capped by both protection limits above, and hands back to solar/battery regulation once the battery recovers or the window closes. No extra setting: it reuses Off-peak windows / Departure time / Energy needed by departure as-is. Without an off-peak window configured, hitting the floor still stops the charge outright, exactly as before.
For an inverter that can go off-grid, or any other boolean condition that
should stop charging outright, point External charge-allowed sensor at a
binary_sensor.* or input_boolean.* entity. on (or the opposite, with
Invert ticked) means charging may proceed; anything else — off,
unavailable, missing — stops it immediately, overriding even an explicit
force_charge_for call, since the point is protecting the house battery from
an EV load it cannot support during an outage. This applies whether or not
surplus mode is on.
Optional and inert until an entity is set. Unlike the power sensors elsewhere in this integration, a missing or unavailable reading here fails closed (blocks charging) rather than open, since a gap in the reading is exactly when this protection matters most.
Set the vehicles option to a comma-separated list, e.g. Zoe, Kangoo. That
adds a select.active_vehicle entity plus one cumulative energy sensor per car.
Charged energy is attributed to whichever car is selected, and totals survive
restarts.
The charger cannot know which car is plugged in, so the select is manual — but it is a normal entity, so an automation can flip it from a BLE/GPS tracker, an NFC tag, or anything else that identifies the car.
Add sensor.energy_session as a device consumption source.
These chargers expose no lifetime meter — only a per-session counter that
resets. The sensor is therefore published as total_increasing, which is exactly
the contract for a resetting meter: Home Assistant treats each reset as a new
cycle and accumulates a correct running total.
Sensors — status (see below), voltage_l1, current_l1, power_l1, and on
3-phase models the matching *_l2 / *_l3; power_total; energy_session,
session_duration; last_session_energy, last_session_duration;
temperature, work_state, work_state_debug, downcounter, selftest,
alarm, adjust_current_options, product_variant; surplus diagnostics
(surplus_raw_w, surplus_effective_w, surplus_target_current_a,
surplus_battery_discharge_over_limit_w, surplus_last_decision_reason).
Number — charge_current, surplus thresholds, battery thresholds.
Switch — charge_session, nfc_enabled, surplus_mode, schedule_enabled.
Select — surplus_profile, plug_in_action, active_vehicle (when
vehicles is set).
Time — schedule_start, schedule_end.
Binary sensor — surplus_regulation_active.
Button — reboot_charger, ready_to_charge.
sensor.status is an enum with stable, translated values — sleep, idle,
plugged_in, charging, waiting, fault, paused, charged — decoded from
the charger's raw state string. Prefer it over work_state_debug in
automations: the raw strings are firmware-specific, these values are not.
select.plug_in_action controls what the charger does when a cable is plugged
in: Prompt, Start charging, or Do nothing. Setting it to Do nothing
is the supported way to stop a car auto-starting a charge. It is unavailable on
firmwares that do not report it.
button.ready_to_charge returns the charger to its ready state after a session.
On some firmwares that is what clears a stale power reading.
sensor.evcc_status reports the IEC 61851 letter evcc
expects — A (no vehicle), B (connected), C (charging) — so the charger can
be driven as an evcc custom charger. WORKING can linger after a completed
charge, so C additionally requires power to actually be flowing.
Writes are skipped when the charger already holds the target value: every DP write makes the charger beep, and controllers re-assert the same setpoint on a timer.
sensor.connection_health reports the share of polls the charger answered, with
the details as attributes: consecutive failures, last success and last failure,
the cached fault verdict, how many times the charger was relocated after an IP
change, and how many times the local_key was re-fetched.
It stays available while the charger is unreachable — that is when it is worth reading. A Tuya charger accepts a single local connection, so contention with the Smart Life app shows up as intermittent failures rather than a clean outage, which is invisible on every other entity.
The same information, plus the last discovery scan, is included in the diagnostics download.
Use Configure → Reconfigure to change the address, device_id or
local_key without deleting the integration — deleting it would discard the
entity history and energy statistics.
pip install -r requirements-test.txt
pytestThe suite stubs Home Assistant rather than installing it, so it runs anywhere in under a second. It asserts DP decoding against payloads captured from real hardware, and guards the regressions that have reached users (options-form serialisation, secret redaction in diagnostics, every module importing).
On single-phase chargers the L2/L3 sensors stay unavailable rather than reporting a misleading 0 V. Power reads 0 whenever the charger is not actively charging, instead of holding the last value.
| Option | Purpose |
|---|---|
scan_interval |
Polling interval, seconds |
charger_profile / charger_profile_json |
DP mapping; custom JSON overrides, validated on save |
continuous_current |
1 A steps (default on) |
max_charge_current_a / min_charge_current_a |
Your circuit's rating; 0 uses the charger's |
max_inverter_power_w / total_load_sensor_entity_id |
Cap total load under a hybrid inverter's rating; 0 disables |
external_charge_allowed_sensor_entity_id, external_charge_allowed_sensor_inverted |
Optional binary_sensor/input_boolean gate; blocks charging entirely, even force_charge_for, when it says no |
vehicles |
Comma-separated car names; enables per-vehicle tracking |
max_house_power_w |
Subscribed power for load balancing; 0 disables it |
off_peak_windows |
22:00-06:00, 12:30-14:30; empty charges at any hour |
off_peak_sensor_entity_id, off_peak_sensor_inverted |
Optional binary_sensor/input_boolean; authoritative over off_peak_windows for scheduling when set |
departure_time / departure_energy_kwh |
Deadline that overrides the off-peak wait |
off_peak_price / peak_price |
Price per kWh; enables session cost estimation |
surplus_mode_enabled |
Master switch for surplus mode |
surplus_sensor_entity_id, surplus_sensor_inverted |
Grid power sensor and its sign |
surplus_start_threshold_w, surplus_stop_threshold_w |
Start/stop thresholds |
surplus_curtailment_sensor_entity_id, surplus_curtailment_sensor_inverted |
Optional, enables zero_injection |
surplus_battery_soc_sensor_entity_id |
Optional battery SOC |
surplus_battery_soc_high_threshold_pct, surplus_battery_soc_low_threshold_pct |
Battery hysteresis |
surplus_battery_net_discharge_sensor_entity_id, surplus_battery_net_discharge_sensor_inverted |
Optional discharge guard |
surplus_allow_battery_discharge_for_ev / surplus_max_battery_discharge_for_ev_w |
Battery budget for the EV |
surplus_forecast_sensor_entity_id |
Optional 1 h solar forecast, anti-drop guard |
All of them take an optional entry_id, which is only needed if you have more
than one charger.
Charge at full rate for a set time, ignoring surplus regulation — the "I need to leave in an hour" button.
action: tuya_ev_charger.force_charge_for
data:
duration_minutes: 60
current_a: 16 # optional; the maximum available current if omittedSuspend surplus regulation for a while without turning the mode off, so it resumes by itself. Useful before running the oven, or while testing.
action: tuya_ev_charger.pause_surplus
data:
duration_minutes: 30Switch between eco, balanced and fast — the same thing
select.surplus_profile does, callable from an automation.
action: tuya_ev_charger.set_surplus_profile
data:
profile: ecoCorrect a per-vehicle total. Attribution depends on select.active_vehicle
being right at the time of the charge, so a forgotten switch credits the wrong
car; this is how you fix it after the fact.
action: tuya_ev_charger.set_vehicle_energy
data:
vehicle: Zoe # must match a name in the `vehicles` option
energy_kwh: 412.5Asks what surplus regulation would do right now, and writes nothing to the charger. The answer names the gate that decided, the gates that declined before it, and every figure weighed — surplus, thresholds, protection caps, the current ladder.
action: tuya_ev_charger.dry_run_surplusThe report arrives as a persistent notification and on the event bus as
tuya_ev_charger_dry_run_surplus. Attach it when reporting that surplus mode
will not start — it turns "it doesn't work" into a reproducible scenario.
It is genuinely side-effect free: the regulation's timers are copied before the question is asked, so calling it cannot consume a start delay or nudge the solar forecast average.
Hands the charger's single local connection to another client for a while — the
Smart Life app, or tinytuya for a DP dump — then takes it back on its own.
action: tuya_ev_charger.release_connection
data:
duration_minutes: 10The socket is closed, not merely idled: an open socket holds the slot even when
nothing is read from it. Entities keep their last values rather than going
unavailable, because the charger is not faulty, it is lent out. 0 minutes
resumes immediately.
Before this, using the phone app meant disabling the whole integration.
Dumps what the charger actually reports and suggests a DP profile. Set
apply: true to apply the suggestion. The report is posted as a persistent
notification and fired on the event bus as tuya_ev_charger_profile_assistant —
attach it when opening an issue about an unsupported model.
action: tuya_ev_charger.profile_assistant
data:
apply: falseThe charger remembers exactly one session: DP 105 is overwritten by the next. "How much did I charge last month" is therefore unanswerable from the device itself.
Each completed session is now logged as it is announced, with its duration, energy, off-peak/peak split, the vehicle it was attributed to, and its estimated cost. The last 60 are kept.
Two entities:
sensor.last_session_cost— cost of the most recent session, with the breakdown as attributes. Unavailable until a price is set.sensor.session_count— how many sessions are on record, with the whole log, total energy and total cost as attributes. Disabled by default, since its attributes are large.
Set Off-peak price per kWh and Peak price per kWh to enable costing. One
of the two is enough for a flat tariff. With both at 0 the cost is reported as
unknown rather than as 0 — a sensor showing 0 € for every session reads as a
working meter reporting free electricity.
The cost is an estimate. The charger gives a duration and a total, with no timestamps and no breakdown, so the off-peak share is reconstructed from the session's wall-clock window and the energy apportioned by time. That is accurate for a wallbox holding a setpoint, and less so while a nearly-full car tapers.
In the automation editor, pick the charger as a device and these appear directly — no need to know which sensor holds the state or what its values are called:
| Trigger | Fires when |
|---|---|
| Started charging | status → charging |
| Finished charging | status → charged |
| Reported a fault | status → fault |
| A cable was plugged in | status → plugged_in |
| Unplugged while charging | status charging → idle |
The last one is a transition, not a state, which is why it is worth exposing: an interrupted charge is otherwise indistinguishable from a normal finish without knowing the internal vocabulary.
Three automation blueprints ship in
blueprints/automation/tuya_ev_charger/:
| Blueprint | What it does |
|---|---|
charge_notifications.yaml |
Notify on charge complete, on a fault, and on an unplug mid-charge |
night_charge.yaml |
Start and stop on a schedule, only when a car is plugged in |
vehicle_from_presence.yaml |
Set select.active_vehicle from a tracker, so energy lands on the right car |
Copy them into config/blueprints/automation/tuya_ev_charger/ and reload
automations, or import them by URL from Settings → Automations → Blueprints →
Import.
night_charge.yaml overlaps with the built-in off-peak windows on purpose: use
the option for a fixed schedule, the blueprint when the schedule has to depend
on something else (a Tempo colour, a calendar, the day of the week).
A dedicated card is available: Tuya EV Charger Card. Install it through HACS as a Lovelace custom repository — it is a separate frontend plugin, not part of this integration.
A simplified YAML example is also provided: lovelace/charge_intelligente.yaml.
The card lives in this repository as a git submodule for development. If you cloned before checking it out:
git submodule update --initA diagnostic script is included: tools/tuya_autodetect_test.py. Run it on a
machine on the same network as the charger.
python3 -m venv /tmp/ttv && /tmp/ttv/bin/pip install tinytuya
/tmp/ttv/bin/python tools/tuya_autodetect_test.py --scantime 30It scans for Tuya devices, reports each one's IP, protocol version and MAC, and tests the control port (6668). Useful flags:
--device-id <gwId>— check that a specific charger is reachable--local-key <key>— perform a real status read and print the grid voltage--config-entries <path/to/.storage/core.config_entries>— cross-check what Home Assistant has stored against what is actually on the network
Common outcomes:
| Symptom | Meaning |
|---|---|
| No Tuya device found | Charger offline, or you are not on its subnet (UDP broadcast does not cross VLANs/repeaters) |
| Found, but port 6668 refused | Something else holds the charger's single local connection (Smart Life app, another Tuya/LocalTuya integration), or the charger is on an isolated AP |
| Found, port open, read fails | Wrong local_key or protocol version |
Stored device_id looks like an IP |
Entry created by an old buggy scan — re-add the charger |
A Tuya charger accepts only one local connection at a time. If Home Assistant cannot connect, make sure no other integration or app is holding it.
Confirmed working:
- dé Portable EV Charger 3.5 kW (single-phase) — reference device
- dé Mobile Wallbox 11 kW, 3-phase (CEE 16 A) — L1/L2/L3 exposed
- Other Tuya chargers using protocol 3.3 / 3.4 / 3.5 with the same DP layout
If your charger reports a different DP layout, use charger_profile_json to
supply a custom mapping, and please open an issue with a DP dump taken while
charging so it can be supported natively.
Repository side: description and valid topics. Integration side: valid
hacs.json keys, issue_tracker in manifest.json, and manifest keys sorted
(domain, name, then alphabetical).
The icon and logo live in custom_components/tuya_ev_charger/brand/.
Since Home Assistant 2026.3, an integration serves its own brand images from
there, taking priority over the central CDN, so no submission to the
home-assistant/brands repo is
needed. On Home Assistant older than 2026.3 the icon falls back to the CDN, where
this integration is not registered, so no icon is shown.
See CONTRIBUTING.md — it says what gets merged, what does not, and why. CHANGELOG.md summarises each release; docs/ROADMAP.md records what is planned and what was deliberately dropped.
Reporting an unsupported charger? The Unsupported or misbehaving charger issue template asks for the three things every previous report has needed a follow-up to obtain.
MIT.