diff --git a/CHANGELOG.md b/CHANGELOG.md index 399f8d4..f50a0d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index 2285c99..21ef6ad 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -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 | diff --git a/devices.yaml b/devices.yaml index 723fd8b..cb0dd99 100644 --- a/devices.yaml +++ b/devices.yaml @@ -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: "" diff --git a/drivers/lua/tesla_wall_connector.lua b/drivers/lua/tesla_wall_connector.lua index 985895e..85e67b7 100644 --- a/drivers/lua/tesla_wall_connector.lua +++ b/drivers/lua/tesla_wall_connector.lua @@ -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, @@ -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) @@ -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) @@ -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", { diff --git a/drivers/tests/lua_harness/test_tesla_wall_connector.lua b/drivers/tests/lua_harness/test_tesla_wall_connector.lua new file mode 100644 index 0000000..1831c39 --- /dev/null +++ b/drivers/tests/lua_harness/test_tesla_wall_connector.lua @@ -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") diff --git a/drivers/tests/test_tesla_wall_connector.py b/drivers/tests/test_tesla_wall_connector.py new file mode 100644 index 0000000..e697309 --- /dev/null +++ b/drivers/tests/test_tesla_wall_connector.py @@ -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 diff --git a/index.yaml b/index.yaml index b701ed6..e66f04c 100644 --- a/index.yaml +++ b/index.yaml @@ -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 diff --git a/manifests/tesla_wall_connector.yaml b/manifests/tesla_wall_connector.yaml index f037d4f..a2870a1 100644 --- a/manifests/tesla_wall_connector.yaml +++ b/manifests/tesla_wall_connector.yaml @@ -1,5 +1,5 @@ name: "tesla_wall_connector" -version: "0.1.1" +version: "0.1.2" tier: community author: "Sourceful Labs AB" protocol: http @@ -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: "" diff --git a/support-status.json b/support-status.json index 4f61ce4..aa17b7b 100644 --- a/support-status.json +++ b/support-status.json @@ -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": {