Skip to content

Instantly share code, notes, and snippets.

@LucaTNT
Created January 16, 2026 14:00
Show Gist options
  • Select an option

  • Save LucaTNT/4adf01a7252386559070023612efa117 to your computer and use it in GitHub Desktop.

Select an option

Save LucaTNT/4adf01a7252386559070023612efa117 to your computer and use it in GitHub Desktop.
ESPHome configuration to run a Modbus adapter that can talk to the Tesla Wall Charger 3 to dynamically adjust power. Includes API connection to a Shelly Pro EM 50 (easily adaptable to another type of Shelly meter)
substitutions:
device_name: esp-tesla
friendly_name: ESPHome Tesla
mqtt_broker: 10.42.1.10 # CHANGE!
tx_pin: GPIO32 # connect to RX on modbus bridge # CHANGE!
rx_pin: GPIO33 # connect to TX on modbus bridge # CHANGE!
ct1_value_id: internal_meter_reading # Name of the (number) variable that needs to be set as ct reading
min_value: 0.0
max_value: 35.0 # adjust to a value above configured max-value in Wall Connector config
start_value: 0.0 # Initial value at boot - determines the behavior when home assistant
# is not dynamically changing the ct amps: set it at zero and the maximum charging amps
# will be available to the car. Set it to max_value and the charger will only offer the car 6A
grid_voltage: 230 # used for power calculation
# serial <-> modbus bridge config
baud_rate: 115200
data_bits: 8
parity: NONE
stop_bits: 1
shelly_pro_em_ip: 10.42.40.250 # CHANGE!
esp32:
board: nodemcu-32s
framework:
type: esp-idf
logger:
level: DEBUG # Don't use DEBUG in production
baud_rate: 0 # disable serial in order to use serial ports for modbus
esphome:
name: ${device_name}
friendly_name: ${friendly_name}
min_version: 2025.12.0
name_add_mac_suffix: false
wifi:
networks:
- ssid: "Your wifi"
password: "Your Password"
min_auth_mode: WPA2
ota:
- platform: esphome
id: esphome_ota
api:
# Get from shelly
http_request:
interval:
- interval: 1s
then:
- if:
condition:
lambda: 'return id(enable_shelly);'
then:
- http_request.get:
url: http://${shelly_pro_em_ip}/rpc/EM1.GetStatus?id=0
capture_response: true
on_response:
then:
- if:
condition:
lambda: return response->status_code == 200;
then:
- lambda: |-
json::parse_json(body, [](JsonObject root) -> bool {
if (root["current"].is<double>()) {
float current = root["current"];
// Round to 1 decimal
id(${ct1_value_id}).publish_state(round(current * 10) / 10);
if (root["voltage"].is<double>()) {
float voltage = root["voltage"];
id(ct1_voltage_v) = voltage;
} else {
ESP_LOGI("Shelly-call", "No 'voltage' key in this json!");
}
return true;
} else {
ESP_LOGI("Shelly-call", "No 'current' key in this json!");
return false;
}
});
else:
- logger.log:
format: "Error: Response status: %d, message %s"
args: [ 'response->status_code', 'body.c_str()' ]
globals:
- id: enable_shelly
type: bool
initial_value: "true"
restore_value: true
- id: all_phases_equal
type: bool
initial_value: "true"
restore_value: true
- id: ct1_voltage_v
type: float
initial_value: "230"
- id: ct2_voltage_v
type: float
initial_value: "230"
- id: ct3_voltage_v
type: float
initial_value: "230"
- id: ct1_power_w
type: float
initial_value: "0.0"
- id: ct2_power_w
type: float
initial_value: "0.0"
- id: ct3_power_w
type: float
initial_value: "0.0"
- id: ct4_power_w
type: float
initial_value: "0.0"
- id: ct_total_w
type: float
initial_value: "0.0"
- id: ct1_current_a
type: float
initial_value: "0.0"
- id: ct2_current_a
type: float
initial_value: "0.0"
- id: ct3_current_a
type: float
initial_value: "0.0"
- id: ct4_current_a
type: float
initial_value: "0.0"
- id: ct_total_a
type: float
initial_value: "0.0"
switch:
- platform: template
id: read_from_shelly
name: "Read from Shelly global"
restore_mode: RESTORE_DEFAULT_ON
lambda: |-
return id(enable_shelly);
turn_on_action:
globals.set:
id: enable_shelly
value: "true"
turn_off_action:
globals.set:
id: enable_shelly
value: "false"
- platform: template
id: all_phases_equal_mode
name: "All-phases-equal mode"
restore_mode: RESTORE_DEFAULT_ON
lambda: |-
return id(all_phases_equal);
turn_on_action:
globals.set:
id: all_phases_equal
value: "true"
turn_off_action:
globals.set:
id: all_phases_equal
value: "false"
button:
- platform: restart
name: "${friendly_name} Restart"
# Power config value
number:
# The number to be changed externally, used if shelly mode is disabled
- platform: template
id: meter_reading
name: "Meter Reading"
icon: "mdi:meter-electric"
disabled_by_default: false
unit_of_measurement: "A"
min_value: ${min_value}
max_value: ${max_value}
step: 0.1
initial_value: ${start_value} # cannot be used with lambda
optimistic: true # update when set
on_value:
then:
lambda: |-
// Copy this value to the internal number, used for modbus
id(internal_meter_reading).publish_state(id(meter_reading).state);
# The actual number used to calculate modbus data
- platform: template
id: internal_meter_reading
icon: "mdi:meter-electric"
disabled_by_default: false
unit_of_measurement: "A"
min_value: ${min_value}
max_value: ${max_value}
step: 0.1
initial_value: ${start_value} # cannot be used with lambda
optimistic: true # update when set
on_value:
then:
lambda: |-
// set ct1_current to meter reading
id(ct1_current_a) = id(${ct1_value_id}).state + id(reading_offset).state;
// set all other ct_currents to same. Ignore ct4 (it was initialized with 0.0).
id(ct2_current_a) = id(ct3_current_a) = id(ct1_current_a);
id(ct_total_a) = id(ct1_current_a) + id(ct2_current_a) + id(ct3_current_a) + id(ct4_current_a);
id(ct1_power_w) = id(ct1_voltage_v) * id(ct1_current_a); // assume cos phi = 1
if (id(all_phases_equal)) {
id(ct2_power_w) = id(ct3_power_w) = id(ct1_power_w); // all amps and watts are equal, ignore ct4
} else {
id(ct2_power_w) = id(ct2_voltage_v) * id(ct2_current_a); // assume cos phi = 1
id(ct3_power_w) = id(ct3_voltage_v) * id(ct3_current_a); // assume cos phi = 1
}
id(ct_total_w) = id(ct1_power_w) + id(ct2_power_w) + id(ct3_power_w) + id(ct4_power_w);
# Add an offset to the current set externally or read by Shelly. Useful to further reduce charging current.
- platform: template
id: reading_offset
name: "Meter reading offset"
unit_of_measurement: "A"
initial_value: 0
step: 0.1
min_value: 0
max_value: ${max_value}
optimistic: true
restore_value: true
on_value:
then:
lambda: |-
// Refresh the meter value if the offset changed
id(${ct1_value_id}).publish_state(id(${ct1_value_id}).state);
sensor:
- platform: template
name: "CT Sensor 1 current"
unit_of_measurement: A
state_class: measurement
device_class: current
lambda: |-
return id(ct1_current_a);
update_interval: 5s
accuracy_decimals: 1
disabled_by_default: true
- platform: template
name: "CT Sensor 1 power"
unit_of_measurement: W
state_class: measurement
device_class: power
lambda: |-
return id(ct1_power_w);
update_interval: 5s
accuracy_decimals: 0
disabled_by_default: true
uart:
- id: wallconn_uart
tx_pin: ${tx_pin}
rx_pin: ${rx_pin}
baud_rate: ${baud_rate}
data_bits: ${data_bits}
parity: ${parity}
stop_bits: ${stop_bits}
modbus:
- id: wallconn_modbus
uart_id: wallconn_uart
role: server
modbus_controller:
- id: wc_mb_server # modbus server (wall conector is client/requestor)
modbus_id: wallconn_modbus
address: 1
server_registers:
# Serial number / MAC address
- { address: 1, value_type: U_WORD, read_lambda: 'return 0x3078;' }
- { address: 2, value_type: U_WORD, read_lambda: 'return 0x3030;' }
- { address: 3, value_type: U_WORD, read_lambda: 'return 0x3030;' }
- { address: 4, value_type: U_WORD, read_lambda: 'return 0x3034;' }
- { address: 5, value_type: U_WORD, read_lambda: 'return 0x3731;' }
- { address: 6, value_type: U_WORD, read_lambda: 'return 0x3442;' }
- { address: 7, value_type: U_WORD, read_lambda: 'return 0x3035;' }
- { address: 8, value_type: U_WORD, read_lambda: 'return 0x3638;' }
- { address: 9, value_type: U_WORD, read_lambda: 'return 0x3631;' }
- { address: 10, value_type: U_WORD, read_lambda: 'return 0x0000;' }
# "1.6.1‑Tesla"
- { address: 11, value_type: U_WORD, read_lambda: 'return 0x312E;' }
- { address: 12, value_type: U_WORD, read_lambda: 'return 0x362E;' }
- { address: 13, value_type: U_WORD, read_lambda: 'return 0x312D;' }
- { address: 14, value_type: U_WORD, read_lambda: 'return 0x5465;' }
- { address: 15, value_type: U_WORD, read_lambda: 'return 0x736C;' }
- { address: 16, value_type: U_WORD, read_lambda: 'return 0x6100;' }
# Four reserved words (all 0xFFFF)
- { address: 17, value_type: U_WORD, read_lambda: 'return 0xFFFF;' }
- { address: 18, value_type: U_WORD, read_lambda: 'return 0xFFFF;' }
- { address: 19, value_type: U_WORD, read_lambda: 'return 0xFFFF;' }
- { address: 20, value_type: U_WORD, read_lambda: 'return 0xFFFF;' }
# "012.00020A.H"
- { address: 21, value_type: U_WORD, read_lambda: 'return 0x3031;' }
- { address: 22, value_type: U_WORD, read_lambda: 'return 0x322E;' }
- { address: 23, value_type: U_WORD, read_lambda: 'return 0x3030;' }
- { address: 24, value_type: U_WORD, read_lambda: 'return 0x3032;' }
- { address: 25, value_type: U_WORD, read_lambda: 'return 0x3041;' }
- { address: 26, value_type: U_WORD, read_lambda: 'return 0x2E48;' }
- { address: 27, value_type: U_WORD, read_lambda: 'return 0x0000;' }
# Reserved
- { address: 28, value_type: U_WORD, read_lambda: 'return 0xFFFF;' }
# Meter number "90954"
- { address: 29, value_type: U_WORD, read_lambda: 'return 0x3930;' }
- { address: 30, value_type: U_WORD, read_lambda: 'return 0x3935;' }
- { address: 31, value_type: U_WORD, read_lambda: 'return 0x3400;' }
# Model? "VAH4810AB0231"
- { address: 32, value_type: U_WORD, read_lambda: 'return 0x5641;' }
- { address: 33, value_type: U_WORD, read_lambda: 'return 0x4834;' }
- { address: 34, value_type: U_WORD, read_lambda: 'return 0x3831;' }
- { address: 35, value_type: U_WORD, read_lambda: 'return 0x3041;' }
- { address: 36, value_type: U_WORD, read_lambda: 'return 0x4230;' }
- { address: 37, value_type: U_WORD, read_lambda: 'return 0x3233;' }
- { address: 38, value_type: U_WORD, read_lambda: 'return 0x3100;' }
# eight more reserved (0xFFFF)
- { address: 39, value_type: U_WORD, read_lambda: 'return 0xFFFF;' }
- { address: 40, value_type: U_WORD, read_lambda: 'return 0xFFFF;' }
- { address: 41, value_type: U_WORD, read_lambda: 'return 0xFFFF;' }
- { address: 42, value_type: U_WORD, read_lambda: 'return 0xFFFF;' }
- { address: 43, value_type: U_WORD, read_lambda: 'return 0xFFFF;' }
- { address: 44, value_type: U_WORD, read_lambda: 'return 0xFFFF;' }
- { address: 45, value_type: U_WORD, read_lambda: 'return 0xFFFF;' }
- { address: 46, value_type: U_WORD, read_lambda: 'return 0xFFFF;' }
# Mac "04:71:4B:05:68:61"
- { address: 47, value_type: U_WORD, read_lambda: 'return 0x3034;' }
- { address: 48, value_type: U_WORD, read_lambda: 'return 0x3A37;' }
- { address: 49, value_type: U_WORD, read_lambda: 'return 0x313A;' }
- { address: 50, value_type: U_WORD, read_lambda: 'return 0x3442;' }
- { address: 51, value_type: U_WORD, read_lambda: 'return 0x3A30;' }
- { address: 52, value_type: U_WORD, read_lambda: 'return 0x353A;' }
- { address: 53, value_type: U_WORD, read_lambda: 'return 0x3638;' }
- { address: 54, value_type: U_WORD, read_lambda: 'return 0x3A36;' }
- { address: 55, value_type: U_WORD, read_lambda: 'return 0x3100;' }
# CT1 power W
- { address: 0x88, value_type: FP32, read_lambda: 'return id(ct1_power_w);' }
# CT2 power W
- { address: 0x8A, value_type: FP32, read_lambda: 'return id(ct2_power_w);' }
# CT3 power W
- { address: 0x8C, value_type: FP32, read_lambda: 'return id(ct3_power_w);' }
# CT4 power W
- { address: 0x8E, value_type: FP32, read_lambda: 'return id(ct4_power_w);' }
# Aggregate watts
- { address: 0x90, value_type: FP32, read_lambda: 'return id(ct_total_w);' }
# Reserved
- { address: 0x92, value_type: U_WORD, read_lambda: 'return 0;' }
# CT1 current amps
- { address: 0xF4, value_type: FP32, read_lambda: 'return id(ct1_current_a);' }
# CT2 current amps
- { address: 0xF6, value_type: FP32, read_lambda: 'return id(ct2_current_a);' }
# CT3 current amps
- { address: 0xF8, value_type: FP32, read_lambda: 'return id(ct3_current_a);' }
# CT4 current amps
- { address: 0xFA, value_type: FP32, read_lambda: 'return id(ct4_current_a);' }
# Total amps
- { address: 0xFC, value_type: FP32, read_lambda: 'return id(ct_total_a);' }
# Initialization handshake
- { address: 40002, value_type: U_WORD, read_lambda: 'return 0x0001;' }
- { address: 40003, value_type: U_WORD, read_lambda: 'return 0x0042;' }
- { address: 40004, value_type: U_WORD, read_lambda: 'return 0x4765;' }
- { address: 40005, value_type: U_WORD, read_lambda: 'return 0x6E65;' }
- { address: 40006, value_type: U_WORD, read_lambda: 'return 0x7261;' }
- { address: 40007, value_type: U_WORD, read_lambda: 'return 0x6300;' }
@Klangen82
Copy link

Hi @LucaTNT,
thanks a lot for sharing this – awesome work!

I’ve tried to replicate your setup with ESPHome + RS485 to a Tesla Wall Connector, but I can’t get the Wall Connector to poll the Modbus registers at all (poll count stays at 0).

Would you mind sharing a bit more detail about your hardware and wiring? For example:

  • Which exact RS485 board/module are you using? (e.g. T-CAN485, MAX485, etc.)
  • How did you wire A/B/GND between the Wall Connector and the RS485 board?
  • Did you need termination resistors (120 ohm) or bias resistors?
  • Did you have to enable anything specific in Tesla Wall Connector via Tesla One (energy meter / Neurio / RS485 mode)?
  • Are you using auto-direction or manual DE/RE control on the RS485 transceiver?

If you have a wiring diagram or photo of your setup, that would be super helpful.
Thanks again for publishing this!

@LucaTNT
Copy link
Author

LucaTNT commented Feb 4, 2026

Hi @Klangen82,
I used this RS485 board from Aliexpress), to which I soldered header pins to connect to my ESP32 and some screw terminals to connect to the TWC. The connection was straight from A to the "+" connection of the TWC and from B to the "-". No ground connection, no resistors, no further configuration on the RS485 board.

Tesla One just saw there was a meter connected and offered to configure it.

Here is a picture of the setup on my desk, I didn't take one while connected to the TWC. Ignore the white/red cable soldered to the ESP32 board, it's a residue from a previous project.

RS485 TWC3

Let me know if you need anything else!

@Klangen82
Copy link

Hi Luca, thanks a lot for the details and the photo, super helpful!

A couple of follow-up questions that might explain why my Wall Connector never starts polling Modbus:

  • What firmware version is your Tesla Wall Connector running?
  • Did you have to follow any specific startup sequence to make Modbus activate? For example:
    • power off Wall Connector
    • connect RS485 / ESP32
    • power on Wall Connector
    • then enable energy meter in Tesla One
  • Did you need to explicitly enable “Energy meter / Neurio / RS485” in Tesla One, or was it auto-detected for you?
  • After enabling the meter in Tesla One, did the Wall Connector require a reboot before it started polling?
  • Do you remember if the Wall Connector started polling immediately, or only after starting a charging session?

I’m trying to rule out firmware differences or a required init sequence on the TWC side.

Thanks again, this is super valuable!

@Klangen82
Copy link

Hi Luca, quick update, I got it working on my side now 🎉
No need to reply to my previous questions.

Thanks again for the help and for sharing your setup, it pointed me in the right direction. Much appreciated!

@LucaTNT
Copy link
Author

LucaTNT commented Feb 5, 2026

Glad you managed to get it working! Would you mind sharing which steps you took/answer your own questions from the previous post just in case anyone stumbles upon this thread in the future?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment