Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,27 @@

## [Unreleased]

### tesla_wall_connector 0.1.2

An idle Wall Connector no longer shows charge power. With no car on the
cable and the contactor open, the unit reads 0 V on its output legs and
about 0.5–1 A of sensor offset. The leg sum was 0, so the driver fell back to
`grid_v × vehicle_current_a`: the voltage on the grid side of the open
contactor times the offset. One field report showed 184 W, and 284 W in the
flow diagram, with no car present. That fallback is the only path in 0.1.1
that gives power with every leg at 0 V.

- Charge power and `charging` now need a closed contactor. A reply without
`contactor_closed` falls back to `vehicle_connected` and then the EVSE
charging states.
- The `grid_v` fallback now runs only when the reply has no leg voltage at
all. A leg that reads 0 V carries no power.
- Split-phase installs and measured leg power through a closed contactor do
not change.

FTW's bundled copy does not move with this commit. Bump the pin and run
`scripts/sync-bundled-drivers.sh`.

### sungrow 1.6.1

An inverter that drops off the network no longer loses registers or its
Expand Down
4 changes: 2 additions & 2 deletions SUPPORT_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,8 @@ Catalog source is not proof that a target can install or run a driver.
| tesla_cloud | 0.2.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed |
| tesla_vehicle | 0.2.6 | ftw-core | not_assessed | — | not_recorded | not_assessed |
| tesla_vehicle | 0.2.6 | blixt-l1 | not_assessed | — | not_recorded | not_assessed |
| tesla_wall_connector | 0.1.1 | ftw-core | not_assessed | — | not_recorded | not_assessed |
| tesla_wall_connector | 0.1.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed |
| tesla_wall_connector | 0.1.2 | ftw-core | not_assessed | — | not_recorded | not_assessed |
| tesla_wall_connector | 0.1.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed |
| teslamate_vehicle | 0.2.0 | ftw-core | not_assessed | — | not_recorded | not_assessed |
| teslamate_vehicle | 0.2.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed |
| tibber | 1.1.2 | ftw-core | not_assessed | — | not_recorded | not_assessed |
Expand Down
2 changes: 1 addition & 1 deletion devices.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1852,7 +1852,7 @@ manufacturers:
protocols:
- protocol: http
driver: "tesla_wall_connector"
version: "0.1.1"
version: "0.1.2"
ders: [ev]
control: false
firmware_versions: ""
Expand Down
46 changes: 26 additions & 20 deletions drivers/lua/tesla_wall_connector.lua
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ DRIVER = {
id = "tesla_wall_connector",
name = "Tesla Wall Connector",
manufacturer = "Tesla",
version = "0.1.1",
version = "0.1.2",
protocols = { "http" },
capabilities = { "ev" },
read_only = true,
Expand Down Expand Up @@ -131,20 +131,31 @@ local function phase_power(v, a)
return v * a
end

local function charge_power(vitals)
if split_phase then
local w = (as_number(vitals.grid_v) or 0) * (as_number(vitals.vehicle_current_a) or 0)
if w < 0 then w = 0 end
return w
-- The contactor joins the car to the grid and closes only with a car on
-- the cable. While it is open no current reaches a car: an idle unit still
-- reads about 0.5–1 A of sensor offset on its legs, and that is not load.
-- A reply without contactor_closed falls back to vehicle_connected and
-- the EVSE state.
local function contactor_closed(state, vitals)
if vitals.contactor_closed ~= nil then return vitals.contactor_closed == true end
if vitals.vehicle_connected == false then return false end
return state == EVSE_CHARGE_LOW or state == EVSE_CHARGE_FULL
end

local function charge_power(state, vitals)
if not contactor_closed(state, vitals) then return 0 end
local grid_w = (as_number(vitals.grid_v) or 0) * (as_number(vitals.vehicle_current_a) or 0)
if grid_w < 0 then grid_w = 0 end
if split_phase then return grid_w end
-- The legs are measured on the car side of the contactor. Only a reply
-- with no leg voltage at all falls back to the grid voltage; a leg that
-- reads 0 V carries no power.
if vitals.voltageA_v == nil and vitals.voltageB_v == nil and vitals.voltageC_v == nil then
return grid_w
end
local w = phase_power(vitals.voltageA_v, vitals.currentA_a)
return phase_power(vitals.voltageA_v, vitals.currentA_a)
+ phase_power(vitals.voltageB_v, vitals.currentB_a)
+ phase_power(vitals.voltageC_v, vitals.currentC_a)
if w <= 0 then
local fallback = (as_number(vitals.grid_v) or 0) * (as_number(vitals.vehicle_current_a) or 0)
if fallback > 0 then w = fallback end
end
return w
end

local function active_phases(vitals)
Expand All @@ -156,16 +167,11 @@ local function active_phases(vitals)
return n
end

local function is_charging(state, vitals)
if state == EVSE_CHARGE_LOW or state == EVSE_CHARGE_FULL then return true end
return vitals.contactor_closed == true
end

local function is_connected(state, vitals)
if vitals.vehicle_connected == true then return true end
return state == EVSE_CONNECTED or state == EVSE_READY
or state == EVSE_HANDSHAKE or state == EVSE_FINISHED
or state == EVSE_WAIT_CAR or is_charging(state, vitals)
or state == EVSE_WAIT_CAR or contactor_closed(state, vitals)
end

function driver_init(config)
Expand Down Expand Up @@ -209,10 +215,10 @@ function driver_poll()
end

local state = as_number(vitals.evse_state) or EVSE_IDLE
local power_w = charge_power(vitals)
local power_w = charge_power(state, vitals)
local session_wh = as_number(vitals.session_energy_wh) or 0
local connected = is_connected(state, vitals)
local charging = is_charging(state, vitals)
local charging = contactor_closed(state, vitals)
local phases = active_phases(vitals)

host.emit("ev", {
Expand Down
130 changes: 130 additions & 0 deletions drivers/tests/lua_harness/test_tesla_wall_connector.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
-- Tesla Wall Connector Gen 3: charge power comes only through a closed
-- contactor with a car on the cable, from measured volts and amps.
dofile("drivers/tests/lua_harness/host_mock.lua")

local BASE = "http://wc.test"

local function poll(vitals, config)
host.reset()
host._http_responses[BASE .. "/api/1/version"] =
'{"serial_number":"TWC-SN-1","part_number":"1529455-02-J","firmware_version":"24.36.3"}'
host._http_responses[BASE .. "/api/1/vitals"] = vitals
dofile("drivers/lua/tesla_wall_connector.lua")
config = config or {}
config.base_url = BASE
driver_init(config)
assert(driver_poll() == 5000)
local ev = host._emitted.ev
assert(ev and #ev == 1, "one ev reading per poll")
return ev[1]
end

local function near(got, want, tol, label)
assert(math.abs(got - want) <= tol,
string.format("%s: got %.1f W, want %.1f W", label, got, want))
end

-- A real idle unit: no car, contactor open, 0 V on every output leg, and
-- 0.8 A and 0.6 A of sensor offset on legs A and B. The connection test
-- read EV 184 W, which is grid_v × vehicle_current_a (230 V × 0.8 A).
-- The report did not show those two fields; these are the values that
-- give 184 W.
local idle = [[{"contactor_closed":false,"vehicle_connected":false,"session_s":0,
"grid_v":230.0,"grid_hz":50.01,"vehicle_current_a":0.8,
"currentA_a":0.8,"currentB_a":0.6,"currentC_a":0.0,"currentN_a":0.0,
"voltageA_v":0.0,"voltageB_v":0.0,"voltageC_v":0.0,
"relay_coil_v":11.9,"pcba_temp_c":21.3,"handle_temp_c":14.2,"mcu_temp_c":27.5,
"uptime_s":86400,"input_thermopile_uv":-210,"prox_v":0.0,
"pilot_high_v":11.9,"pilot_low_v":11.9,"session_energy_wh":0.0,
"config_status":5,"evse_state":1,"current_alerts":[]}]]
local r = poll(idle)
assert(r.w == 0, "idle unit with open contactor reported " .. tostring(r.w) .. " W")
assert(r.connected == false and r.charging == false)
assert(host._metrics.ev_power_w.value == 0, "ev_power_w must match w")
-- The raw sensor reading stays visible as a diagnostic.
assert(host._metrics.ev_l1_a.value == 0.8 and host._metrics.ev_l1_v.value == 0)

-- A larger offset sample is still no load. The same report's flow
-- diagram showed 284 W; 1.2 A × 236.7 V is one pair that gives it.
r = poll((idle:gsub('"vehicle_current_a":0.8', '"vehicle_current_a":1.2')
:gsub('"grid_v":230.0', '"grid_v":236.7')))
assert(r.w == 0, "offset sample reported " .. tostring(r.w) .. " W")

-- A reply without contactor_closed: no car means no charge power.
r = poll('{"vehicle_connected":false,"evse_state":1,"grid_v":230,"vehicle_current_a":0.8,'
.. '"currentA_a":0.8,"currentB_a":0.6,"voltageA_v":0,"voltageB_v":0,"voltageC_v":0}')
assert(r.w == 0 and r.charging == false)

-- Car on the cable, contactor open (waiting or finished): still no load.
r = poll((idle:gsub('"vehicle_connected":false', '"vehicle_connected":true')
:gsub('"evse_state":1', '"evse_state":9')))
assert(r.w == 0, "open contactor with car reported " .. tostring(r.w) .. " W")
assert(r.connected == true and r.charging == false)

-- The EVSE state alone does not close the contactor.
r = poll((idle:gsub('"vehicle_connected":false', '"vehicle_connected":true')
:gsub('"evse_state":1', '"evse_state":11')))
assert(r.w == 0 and r.charging == false, "state 11 with open contactor")

-- A contactor that says closed while every leg reads 0 V: the grid
-- voltage must not stand in for the 0 V the charger measured.
r = poll((idle:gsub('"contactor_closed":false', '"contactor_closed":true')))
assert(r.w == 0, "closed contactor at 0 V reported " .. tostring(r.w) .. " W")

-- Split-phase installs use grid_v × vehicle_current_a: the same gate holds.
r = poll(idle, { split_phase = true })
assert(r.w == 0, "split-phase idle reported " .. tostring(r.w) .. " W")

-- Three-phase charging: the sum of measured V × A on each leg, positive.
local charging3 = [[{"contactor_closed":true,"vehicle_connected":true,"session_s":1800,
"grid_v":230.0,"grid_hz":50.0,"vehicle_current_a":16.0,
"currentA_a":16.1,"currentB_a":16.0,"currentC_a":16.2,"currentN_a":0.3,
"voltageA_v":230.0,"voltageB_v":229.5,"voltageC_v":230.4,
"session_energy_wh":4200.0,"config_status":5,"evse_state":11,"current_alerts":[]}]]
r = poll(charging3)
-- 230×16.1 + 229.5×16.0 + 230.4×16.2 = 11107.48
near(r.w, 11107.48, 0.01, "three-phase charging")
assert(r.connected == true and r.charging == true and r.phases == 3)
assert(r.session_wh == 4200)
assert(host._metrics.ev_power_w.value == r.w)

-- One-phase car: unused legs sit at a few volts and do not count.
r = poll([[{"contactor_closed":true,"vehicle_connected":true,"grid_v":230,
"vehicle_current_a":16,"currentA_a":16,"currentB_a":0.2,"currentC_a":0.1,
"voltageA_v":230,"voltageB_v":2.4,"voltageC_v":1.8,"session_energy_wh":100,"evse_state":11}]])
near(r.w, 3680, 0.01, "one-phase charging")
assert(r.phases == 1 and r.charging == true)

-- Power measured through a closed contactor counts even if the cable
-- flag disagrees: a status flag does not cancel measured volts and amps.
r = poll([[{"contactor_closed":true,"vehicle_connected":false,"grid_v":230,
"vehicle_current_a":16,"currentA_a":16,"voltageA_v":230,"voltageB_v":0,"voltageC_v":0,
"session_energy_wh":1,"evse_state":11}]])
near(r.w, 3680, 0.01, "closed contactor, measured leg")

-- Contactor closed, legs read about 0 V: no power from grid_v.
r = poll([[{"contactor_closed":true,"vehicle_connected":true,"grid_v":230,
"vehicle_current_a":10,"voltageA_v":2,"voltageB_v":1,"voltageC_v":0,
"session_energy_wh":1,"evse_state":11}]])
assert(r.w == 0, "legs at 0 V reported " .. tostring(r.w) .. " W")

-- A reply with no leg voltage at all: the car sees the measured grid
-- voltage through the closed contactor.
r = poll([[{"contactor_closed":true,"vehicle_connected":true,"grid_v":230,
"vehicle_current_a":10,"session_energy_wh":1,"evse_state":11}]])
near(r.w, 2300, 0.01, "no leg voltages, grid voltage")

-- Split-phase charging: grid_v × vehicle_current_a.
r = poll([[{"contactor_closed":true,"vehicle_connected":true,"grid_v":240,
"vehicle_current_a":32,"currentA_a":40,"currentB_a":40,
"voltageA_v":120,"voltageB_v":120,"session_energy_wh":50,"evse_state":11}]],
{ split_phase = true })
near(r.w, 7680, 0.01, "split-phase charging")

-- A reply without contactor_closed falls back to the EVSE state.
r = poll([[{"vehicle_connected":true,"evse_state":11,"grid_v":230,"vehicle_current_a":16,
"currentA_a":16,"voltageA_v":230,"voltageB_v":0,"voltageC_v":0}]])
near(r.w, 3680, 0.01, "no contactor field, charging state")
assert(r.charging == true)

print("tesla_wall_connector: all cases pass")
12 changes: 12 additions & 0 deletions drivers/tests/test_tesla_wall_connector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Tesla Wall Connector reports charge power only through a closed contactor."""
from pathlib import Path
import subprocess


def test_tesla_wall_connector_charge_power():
root = Path(__file__).resolve().parents[2]
result = subprocess.run(
[str(root / "lua55"), "drivers/tests/lua_harness/test_tesla_wall_connector.lua"],
cwd=root, text=True, capture_output=True, check=False,
)
assert result.returncode == 0, result.stdout + result.stderr
6 changes: 3 additions & 3 deletions index.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -739,14 +739,14 @@ drivers:
size_bytes: 10096
sha256: "d1b8a2cbea20630dcab3cb306df0f6924b0f9ad7f31bcf367fb1d41ca7833350"
- name: "tesla_wall_connector"
version: "0.1.1"
version: "0.1.2"
tier: community
protocol: http
connectivity: local
ders: [ev]
control: false
size_bytes: 8162
sha256: "66b6313eda4f68a92a7cc55931baa2408cb6cbb249145f7e6005456fb821d284"
size_bytes: 8786
sha256: "c365aad2a1216fad870b7dadb32e87feed75627714eeb8160f22cec4db651a80"
- name: "teslamate_vehicle"
version: "0.2.0"
tier: community
Expand Down
6 changes: 3 additions & 3 deletions manifests/tesla_wall_connector.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: "tesla_wall_connector"
version: "0.1.1"
version: "0.1.2"
tier: community
author: "Sourceful Labs AB"
protocol: http
Expand All @@ -15,9 +15,9 @@ tested_devices:
notes: "Tesla Wall Connector Gen 3 on the LAN. Observation only; no local current setpoint."
min_driver_version: "0.1.0"
min_host_version: "2.0.0"
size_bytes: 8162
size_bytes: 8786
dkb_id: "tesla_wall_connector"
sha256: "66b6313eda4f68a92a7cc55931baa2408cb6cbb249145f7e6005456fb821d284"
sha256: "c365aad2a1216fad870b7dadb32e87feed75627714eeb8160f22cec4db651a80"
signature: ""

bytecode_sha256: ""
2 changes: 1 addition & 1 deletion support-status.json
Original file line number Diff line number Diff line change
Expand Up @@ -1682,7 +1682,7 @@
},
{
"catalog_source": true,
"catalog_version": "0.1.1",
"catalog_version": "0.1.2",
"driver_id": "tesla_wall_connector",
"targets": {
"blixt-l1": {
Expand Down
Loading