feat: initial AC telemetry Lua plugin

- CSP Lua app that reads telemetry via ac.* API
- Sends JSON over UDP to configurable target (default 127.0.0.1:5005)
- Purpose-built serializer with zero GC pressure at 60Hz
- Pre-allocated buffer tables, no per-frame allocations
- Graceful fallback when ac global is absent (testing outside AC)
- .gitignore for .idea/
This commit is contained in:
Jan-Peter von Hunnius 2026-06-01 15:13:18 +02:00
commit 3900d2a6b2
7 changed files with 264 additions and 0 deletions

21
src/json.lua Normal file
View file

@ -0,0 +1,21 @@
-- src/json.lua - Purpose-built telemetry serializer for ACTelemetry
-- Writes directly to a string buffer without table allocation or double traversal.
local json = {}
function json.encode_telemetry(d)
local g = d.g_forces
local ws = d.wheel_slip
local st = d.suspension_travel
return string.format(
'{"timestamp":%d,"speed_kmh":%.1f,"rpm":%.0f,"gear":%d,"throttle":%.3f,"brake":%.3f,"steering":%.3f,"g_forces":{"x":%.3f,"y":%.3f,"z":%.3f},"wheel_slip":[%.3f,%.3f,%.3f,%.3f],"suspension_travel":[%.3f,%.3f,%.3f,%.3f],"lap_time":%.3f,"lap_count":%d}',
d.timestamp, d.speed_kmh, d.rpm, d.gear,
d.throttle, d.brake, d.steering,
g.x, g.y, g.z,
ws[1], ws[2], ws[3], ws[4],
st[1], st[2], st[3], st[4],
d.lap_time, d.lap_count
)
end
return json

144
src/telemetry.lua Normal file
View file

@ -0,0 +1,144 @@
-- src/telemetry.lua - Telemetry reader and UDP sender for ACTelemetry
-- Reads car/session state via the CSP Lua API and sends JSON over UDP at a configured rate.
local config = require("config")
local json = require("src.json")
local telemetry = {}
local socket = require("socket.udp")
local udp = socket()
local last_send_time = 0
local ac_available = type(ac) == "table" and type(ac.getCarState) == "function"
local buf_g_forces = { x = 0, y = 0, z = 0 }
local buf_wheel_slip = { 0, 0, 0, 0 }
local buf_suspension_travel = { 0, 0, 0, 0 }
local buf = {
timestamp = 0,
speed_kmh = 0,
rpm = 0,
gear = 0,
throttle = 0,
brake = 0,
steering = 0,
g_forces = buf_g_forces,
wheel_slip = buf_wheel_slip,
suspension_travel = buf_suspension_travel,
lap_time = 0,
lap_count = 0,
}
function telemetry.collect()
if not ac_available then
buf.timestamp = os.time()
buf.speed_kmh = 0
buf.rpm = 0
buf.gear = 0
buf.throttle = 0
buf.brake = 0
buf.steering = 0
buf_g_forces.x = 0
buf_g_forces.y = 0
buf_g_forces.z = 0
buf_wheel_slip[1] = 0 buf_wheel_slip[2] = 0 buf_wheel_slip[3] = 0 buf_wheel_slip[4] = 0
buf_suspension_travel[1] = 0 buf_suspension_travel[2] = 0 buf_suspension_travel[3] = 0 buf_suspension_travel[4] = 0
buf.lap_time = 0
buf.lap_count = 0
return buf
end
local car = ac.getCarState(0)
buf.timestamp = os.time()
buf.speed_kmh = car.speedKmh or 0
buf.rpm = car.rpm or car.rpms or 0
buf.gear = car.gear or 0
buf.throttle = car.gas or 0
buf.brake = car.brake or 0
buf.steering = car.steer or 0
local gx, gy, gz = 0, 0, 0
if car.G then
gx = car.G.x or 0
gy = car.G.y or 0
gz = car.G.z or 0
end
buf_g_forces.x = gx
buf_g_forces.y = gy
buf_g_forces.z = gz
buf_wheel_slip[1] = 0 buf_wheel_slip[2] = 0 buf_wheel_slip[3] = 0 buf_wheel_slip[4] = 0
buf_suspension_travel[1] = 0 buf_suspension_travel[2] = 0 buf_suspension_travel[3] = 0 buf_suspension_travel[4] = 0
if car.wheels then
for i = 1, math.min(#car.wheels, 4) do
local w = car.wheels[i]
buf_wheel_slip[i] = w.slip or 0
buf_suspension_travel[i] = w.suspensionTravel or 0
end
elseif car.wheelSlips then
for i = 1, math.min(#car.wheelSlips, 4) do
buf_wheel_slip[i] = car.wheelSlips[i] or 0
end
if car.suspensionTravel then
for i = 1, math.min(#car.suspensionTravel, 4) do
buf_suspension_travel[i] = car.suspensionTravel[i] or 0
end
elseif car.wheelSuspensionTravel then
for i = 1, math.min(#car.wheelSuspensionTravel, 4) do
buf_suspension_travel[i] = car.wheelSuspensionTravel[i] or 0
end
end
end
local lap_time = 0
if car.lapTimeMs then
lap_time = car.lapTimeMs / 1000
elseif car.lapTime then
lap_time = car.lapTime
end
buf.lap_time = lap_time
local lap_count = 0
if car.lapCount then
lap_count = car.lapCount
elseif car.lapIndex then
lap_count = car.lapIndex
end
buf.lap_count = lap_count
return buf
end
local payload_buf = ""
local function do_send()
udp:sendto(payload_buf, config.target_host, config.target_port)
end
function telemetry.send(data)
payload_buf = json.encode_telemetry(data)
local ok, err = pcall(do_send)
if not ok then
if ac_available then
ac.log("[ACTelemetry] UDP send failed: " .. tostring(err))
else
print("[ACTelemetry] UDP send failed: " .. tostring(err))
end
end
end
function telemetry.update(dt)
local now = os.clock()
if now - last_send_time >= config.send_interval then
last_send_time = now
local data = telemetry.collect()
telemetry.send(data)
end
end
return telemetry