-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathctek.lua
More file actions
337 lines (304 loc) · 13.1 KB
/
Copy pathctek.lua
File metadata and controls
337 lines (304 loc) · 13.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
-- CTEK Chargestorm EV Charger Driver (Automation API v1)
-- Emits: EV
-- Protocol: Modbus/TCP
--
-- Hardware + firmware:
-- Requires a CTEK Chargestorm Connected 2 or Connected 3 running
-- CSOS (Chargestorm OS) firmware 4.9.3 or later. Older firmware
-- either lacks the Modbus/TCP automation server entirely (CSOS
-- < 3.11.16) or lacks the charging-limit control register (CSOS
-- < 4.9.0). Connect the CCU to LAN and:
--
-- Automation → ModbusTCPEnable = true
-- Automation → modbus_tcp_automation_api_version = 1
--
-- Use drivers/ctek_v2.lua if you've set the API version to 2 on the
-- CCU instead. The only difference between the two variants is the
-- base register address (0x1xxx for v1, 0x2xxx for v2); all register
-- semantics, units, and function codes are identical. CTEK recommends
-- running either the charging-control API or the NANOGRID™ limit
-- control on API v2 if you want both enabled simultaneously.
--
-- Unit identifier selects the outlet on dual-outlet stations:
-- unit_id = 1 → EVSE1 (left outlet, or single-outlet station)
-- unit_id = 2 → EVSE2 (right outlet)
--
-- Register map (source: CTEK "Automation interface" v1.0, rev 6b4af7):
--
-- Identity / meter type:
-- 0x1000 API version (u16)
-- 0x1001 API status, 0=OK (u16)
-- 0x1002 EnergyMeterType (u16 enum)
-- 0x1003..0x1008 Serial (12 ASCII chars, 2 per register, big-endian bytes)
--
-- Telemetry (one contiguous read 0x1100..0x1108 = 9 regs):
-- 0x1100..0x1101 Lifetime energy (Wh, u32 big-endian, high word first)
-- 0x1102..0x1104 Per-phase current, L1/L2/L3 (u16 × 10⁻³ A, i.e. mA)
-- 0x1105..0x1107 Per-phase voltage L1-N/L2-N/L3-N (u16 × 10⁻¹ V)
-- 0x1108 Total active power (u16 W)
--
-- Control:
-- 0x1200 Charging limit (u16 A, read/write) — 0 disables
-- charging; values 1..5 are treated as 0 by the
-- charger (IEC 61851 minimum is 6 A). Setpoint is
-- lost on charger restart.
-- 0x1201 Maximum assignment (u16 A, read-only) — upper
-- bound the charger will accept given current
-- de-rating, schedules, NANOGRID™ curtailment, etc.
--
-- Sign convention (SITE = positive W flows INTO the site):
-- ev.w: always positive when charging — an EVSE is a one-way load;
-- there's no vehicle-to-grid path on Chargestorm.
--
-- Config example (config.yaml):
-- drivers:
-- - name: ctek
-- lua: drivers/ctek.lua
-- capabilities:
-- modbus:
-- host: 192.168.1.190
-- port: 502
-- unit_id: 1 # 1 = EVSE1, 2 = EVSE2
-- config:
-- phases: 3 # 1 or 3; default 3
-- min_a: 6 # minimum charge current (A); default 6
-- max_a: 16 # fuse-limited max (A); default 16
-- voltage_v: 230 # nominal per-phase voltage; default 230
DRIVER = {
host_api_min = 1,
host_api_max = 1,
id = "ctek-chargestorm",
name = "CTEK Chargestorm (API v1)",
manufacturer = "CTEK",
version = "0.2.0",
protocols = { "modbus" },
capabilities = { "ev" },
description = "CTEK Chargestorm Connected 2/3 via Modbus/TCP Automation API v1 (CSOS ≥ 4.9.3). Full telemetry + current-limit control.",
homepage = "https://www.ctek.com",
authors = { "FTW contributors" },
tested_models = { "Chargestorm Connected 2", "Chargestorm Connected 3" },
verification_status = "beta",
verification_notes = "Register map per CTEK Automation interface v1.0; charging-limit write verified against CSOS 4.9.x. Derived charging/connected flags approximate the real EVSE state since the state code is MQTT-only.",
connection_defaults = {
port = 502,
unit_id = 1,
},
}
PROTOCOL = "modbus"
----------------------------------------------------------------------------
-- Register addresses — API v1.
-- The v2 variant (drivers/ctek_v2.lua) differs ONLY in the base offsets
-- (0x2xxx instead of 0x1xxx); keep the two files in sync when register
-- semantics change.
----------------------------------------------------------------------------
local REG_API_VERSION = 0x1000
local REG_API_STATUS = 0x1001
local REG_METER_TYPE = 0x1002
local REG_SERIAL_BASE = 0x1003 -- 6 regs → 12 ASCII chars
local REG_TELEMETRY = 0x1100 -- 9 regs: energy(2) + I(3) + V(3) + W(1)
local REG_CHARGE_LIMIT = 0x1200 -- r/w
local REG_MAX_ASSIGN = 0x1201 -- r/o
----------------------------------------------------------------------------
-- Runtime config (overridden from config.yaml in driver_init)
----------------------------------------------------------------------------
local phases = 3
local min_a = 6
local max_a = 16
local voltage_v = 230
-- Last setpoint we successfully wrote. Used as the resume target when
-- ev_start / ev_resume come in without a specific current.
local last_set_a = 0
local sn_read = false
----------------------------------------------------------------------------
-- Helpers
----------------------------------------------------------------------------
local function clamp_amps(a)
if a == nil then return 0 end
a = math.floor(a + 0.5)
if a <= 0 then return 0 end
if a < min_a then return 0 end -- below minimum → pause
if a > max_a then return max_a end
return a
end
local function watts_to_amps(power_w)
if not power_w or power_w <= 0 then return 0 end
return math.floor((power_w / voltage_v / phases) + 0.5)
end
-- Decode a SunSpec/CTEK-style ASCII block where each u16 register packs
-- two bytes of the string in big-endian order. The charger pads the
-- tail with NUL bytes; stop at the first one to get a clean serial.
local function decode_ascii(regs, n)
local s = ""
for i = 1, n do
local r = regs[i] or 0
local hi = math.floor(r / 256)
local lo = r % 256
if hi == 0 then break end
if hi >= 32 and hi < 127 then s = s .. string.char(hi) end
if lo == 0 then break end
if lo >= 32 and lo < 127 then s = s .. string.char(lo) end
end
return s
end
local function write_setpoint(amps)
local err = host.modbus_write(REG_CHARGE_LIMIT, amps)
if err ~= nil and err ~= "" then
host.log("warn", "CTEK: write charging limit failed: " .. tostring(err))
return false
end
last_set_a = amps
return true
end
local function read_setpoint()
local ok, regs = pcall(host.modbus_read, REG_CHARGE_LIMIT, 1, "holding")
if not ok or not regs or not regs[1] then return nil end
return regs[1]
end
----------------------------------------------------------------------------
-- Driver interface
----------------------------------------------------------------------------
function driver_init(config)
host.set_make("CTEK")
if config then
if tonumber(config.phases) then
local p = math.floor(tonumber(config.phases))
if p == 1 or p == 3 then phases = p end
end
if tonumber(config.min_a) then min_a = math.floor(tonumber(config.min_a)) end
if tonumber(config.max_a) then max_a = math.floor(tonumber(config.max_a)) end
if tonumber(config.voltage_v) then voltage_v = tonumber(config.voltage_v) end
end
if min_a < 6 then min_a = 6 end -- IEC 61851 floor
if max_a < min_a then max_a = min_a end
-- Sanity-check the CCU is serving API v1 on this unit. A mismatch
-- here means the operator enabled v2 instead — suggest the v2
-- driver rather than silently reading back garbage.
local ok, api_regs = pcall(host.modbus_read, REG_API_VERSION, 2, "holding")
if ok and api_regs and api_regs[1] then
local api_ver = api_regs[1]
local api_status = api_regs[2] or 0
host.log("info", string.format(
"CTEK: API v%d, status %d (expected v1 from this driver; use drivers/ctek_v2.lua for v2)",
api_ver, api_status))
end
host.log("info", string.format(
"CTEK: driver initialized (phases=%d, min=%dA, max=%dA, V=%.0f)",
phases, min_a, max_a, voltage_v))
local cur = read_setpoint()
if cur then
last_set_a = cur
host.log("info", "CTEK: charge limit readback = " .. tostring(cur) .. "A")
end
end
function driver_poll()
-- One-shot serial read. Keys device identity to the EVSE serial so
-- battery/other models keyed on device_id stay stable across driver
-- renames.
if not sn_read then
local ok_sn, sn_regs = pcall(host.modbus_read, REG_SERIAL_BASE, 6, "holding")
if ok_sn and sn_regs then
local sn = decode_ascii(sn_regs, 6)
if #sn > 0 then
host.set_sn(sn)
sn_read = true
end
end
end
-- Telemetry block: 9 registers in one transaction so the energy
-- counter, per-phase current + voltage, and total power all come
-- from a consistent snapshot.
local ok_tel, tel = pcall(host.modbus_read, REG_TELEMETRY, 9, "holding")
local ev_w = 0
local i_l1, i_l2, i_l3 = 0, 0, 0
local v_l1, v_l2, v_l3 = 0, 0, 0
local lifetime_wh = 0
if ok_tel and tel then
lifetime_wh = host.decode_u32_be(tel[1], tel[2])
i_l1 = (tel[3] or 0) / 1000
i_l2 = (tel[4] or 0) / 1000
i_l3 = (tel[5] or 0) / 1000
v_l1 = (tel[6] or 0) / 10
v_l2 = (tel[7] or 0) / 10
v_l3 = (tel[8] or 0) / 10
ev_w = tel[9] or 0
else
host.log("warn", "CTEK: telemetry block read failed")
end
-- Control setpoints: charging limit (current target) + max
-- assignment (upper bound the charger will honour). The Chargestorm
-- itself exposes no "connector state" flag on Modbus — the clean
-- state code ("CHRG", "PAUS", "EVRD", …) is MQTT-only. Derive
-- charging / connected conservatively from the current + power
-- readings so the dispatch clamp has something to key off.
local limit, max_assign = last_set_a, max_a
local ok_ctl, ctl = pcall(host.modbus_read, REG_CHARGE_LIMIT, 2, "holding")
if ok_ctl and ctl then
limit = ctl[1] or last_set_a
max_assign = ctl[2] or max_a
last_set_a = limit
end
local max_phase_a = math.max(i_l1, i_l2, i_l3)
local charging = (ev_w > 100) or (max_phase_a > 1.0)
-- "Connected" is noisy over Modbus alone: when the car is plugged
-- but not yet authenticated or ramping, current + power both read
-- zero. Report connected whenever the charger is actively delivering
-- OR whenever a non-zero limit is in effect — the latter covers
-- "plan has scheduled a charging window; cable plugged".
local connected = charging or (limit >= min_a and max_assign > 0)
host.emit("ev", {
w = ev_w,
connected = connected,
charging = charging,
max_a = limit,
phases = phases,
l1_v = v_l1, l2_v = v_l2, l3_v = v_l3,
l1_a = i_l1, l2_a = i_l2, l3_a = i_l3,
lifetime_wh = lifetime_wh,
})
host.emit_metric("ev_set_current_a", limit)
host.emit_metric("ev_max_assign_a", max_assign)
host.emit_metric("ev_l1_a", i_l1)
host.emit_metric("ev_l2_a", i_l2)
host.emit_metric("ev_l3_a", i_l3)
host.emit_metric("ev_l1_v", v_l1)
host.emit_metric("ev_l2_v", v_l2)
host.emit_metric("ev_l3_v", v_l3)
host.emit_metric("ev_power_w", ev_w)
host.emit_metric("ev_lifetime_wh", lifetime_wh)
return 5000
end
function driver_command(action, power_w, cmd)
if action == "init" or action == "deinit" then
return true
end
if action == "ev_set_current" then
local amps = clamp_amps(watts_to_amps(power_w))
host.log("debug", "CTEK: ev_set_current "
.. tostring(power_w) .. "W → " .. tostring(amps) .. "A")
return write_setpoint(amps)
end
if action == "ev_pause" then
return write_setpoint(0)
end
if action == "ev_start" or action == "ev_resume" then
-- Without a target current in the command, resume at whatever
-- setpoint was in effect before the pause. If we've never
-- written one (first start after boot), fall back to max_a so
-- the car actually draws current instead of sitting idle.
local amps = (last_set_a and last_set_a >= min_a) and last_set_a or max_a
return write_setpoint(amps)
end
host.log("warn", "CTEK: unknown action " .. tostring(action))
return false
end
function driver_default_mode()
-- EV chargers have no "autonomous self-consumption" equivalent.
-- The Chargestorm will continue at the last-written current limit
-- until the operator changes it or the car unplugs, which is the
-- right behaviour when the EMS loses contact — the user still
-- gets their car charged. Matches the Easee driver's stance.
end
function driver_cleanup()
-- No-op: leave the setpoint where it is so a service restart
-- doesn't interrupt an active charging session.
end