Dorky Commander

July 3, 2026 Β· View on GitHub

πŸ’‘ This is where the fun begins. You can turn your commander into a smart device that reacts to the car CAN signals. Dive in!

Scripting is based on Berry - a Python like language: https://berry-lang.github.io/

Scripts are .be files stored on LittleFS under /scripts/. Saving can also write a .bep preprocessed runtime file with DBC names linked to numeric refs. Each source file can carry metadata comments at the top and must define a setup() function.

Script structure

# @name My script
# @description What it does
# @bus 0

var my_var = 0

def setup()
  # called when the script is enabled β€” register callbacks and timers here
  timer_every(1000, def()
    print("tick " .. str(my_var))
    my_var += 1
  end)
end

def teardown()
  # called when the script is disabled (optional)
  print("bye")
end

Metadata fields (@name, @description, @bus) appear in the web UI.


CAN signals (requires a DBC loaded on the target bus)

# Poll a signal via message draft + timer.
# can_msg_get returns the latest received frame for a given bus + message ID.
var d = can_msg_get(0, "VCRIGHT_doorStatus")
if d != nil
  var v = msg_sig_get(d, "VCRIGHT_frontHandlePulled")
  print("handle: " .. str(v))
end

# Read current value synchronously (returns a map or nil).
var d = can_msg_get(0, "DI_speed")
var m = 0
if d != nil m = msg_sig_get(d, "DI_vehicleSpeed") end
if m != nil
  print("speed: " .. str(m['value']))
end

Raw CAN frames

# Send a raw frame: bus (0 or 1), CAN ID (integer), payload (bytes object, max 8).
var b = bytes("01FF")               # from hex string
can_send_raw(0, 0x100, b)

# Using setbits(offset_bits, len_bits, val) β€” chainable, returns self.
# UI_mirrorFoldRequest at bit 24, 2 bits, value 0x2 (unfold):
can_send_raw(0, 0x273, bytes(-8).setbits(24, 2, 0x2))

# Read the last received payload bytes for a specific CAN ID:
# Default timeout is 1 s β€” blocks up to 1 s for initial data.
# Pass timeout_ms=0 for instant return (retuns nil if no data yet).
var payload = can_recv_raw(0, 0x118)
if payload != nil
  print("DI_state: " .. str(payload))
end

Note: long timeouts are not recommended - as they block all berry scripts.

Encoded messages (requires DBC)

# Create a fresh zeroed draft from DBC (name β†’ ID + DLC resolved at compile time)
var msg = can_msg_new("UI_powertrainControl")
# Or with numeric ID, DLC:  can_msg_new(0x313, 8)
msg_sig_set(msg, "UI_trackModeRequest", 1)
can_msg_send(0, msg)                           # bus, msg

# Or fetch the latest received frame as a modifiable draft
var msg2 = can_msg_get(0, "UI_powertrainControl")   # bus first, then DBC name
# or by numeric CAN ID: can_msg_get(0, 0x313)
msg_sig_set(msg2, "UI_trackModeRequest", 1)
can_msg_send(0, msg2)

The draft is a map instance with keys id (int), data (bytes), dlc (int). You can read/write individual signals with msg_sig_set, or access the raw data buffer directly: msg["data"].

Low-level bit encoding (used by the preprocessor, exposed for custom work)

# Extract a raw integer bit field from bytes.
var raw = bit_extract(data, 0, 2, false)   # start_bit=0, len=2, little_endian

# Insert a raw integer into bytes, returns a new bytes copy.
var encoded = bit_insert(data, 0, 2, false, 3)

# Decode/encode are done via msg_sig_get / msg_sig_set on a draft β€” see below.

AI authoring guidance

When writing scripts that inject a vehicle command frame, send only the intended command frame unless the user explicitly asks for a release/reset frame. No need to set up periodic reset - the vehicle will take care of resetting the state back to idle or similar.


Timers

# Fire once after 500 ms. Returns a handle (int).
var h1 = timer_after(500, def()
  print("fired once")
end)

# Fire every 200 ms. Returns a handle (int) for cancellation.
var h2 = timer_every(200, def()
  print("every 200 ms")
end)

# Cancel by handle (no-op if handle is invalid or already fired).
timer_cancel(h2)

Actions (appear as tiles on the Events page)

action_register("my_action", def()
  print("tile tapped")
  led_set(0, 40, 0)
  timer_after(200, /-> led_off())
end)

action_invoke("my_action")   # call programmatically

LED

led_set(40, 0, 0)   # r, g, b  (0–255 each)
led_off()

Persistent storage (NVS flash β€” survives reboot)

state_set("my_key", "hello")          # write a string
var v = state_get("my_key", "fallback")  # read, with default
state_remove("my_key")                  # delete key

Function reference

High-level API (user-facing β€” write these directly in scripts)

Not renamed by the preprocessor (the call you write is the call that compiles):

FunctionReturnsDescription
can_msg_new(name)draft | nilCreate zeroed draft from DBC name (name→ID+DLC resolved at compile time)
can_msg_new(id, dlc)draft | nilCreate zeroed draft from numeric ID and DLC
can_msg_get(bus, id | name)draft | nilLatest received frame as editable draft β€” bus is first
can_msg_send(bus, draft)β€”Transmit the draft on bus (auto-handles checksum/counter)
can_send_raw(bus, id, data)β€”Transmit a raw CAN frame (max 8 bytes)
can_recv_raw(bus, msg_id [, timeout_ms])bytes | nilLast payload for a CAN ID. Default 1000 ms timeout β€” blocks up to 1 s for initial data. Pass 0 for instant return.
timer_after(ms, fn)handleFire fn once after ms
timer_every(ms, fn)handleFire fn every ms
timer_cancel(handle)β€”Cancel a timer by handle
action_register(name, fn)β€”Register a Dashboard tile
action_invoke(name)β€”Invoke an action by name
led_set(r, g, b)β€”On-board RGB LED (0–255 each)
led_off()β€”Turn off LED
state_set(key, val)β€”Write string to NVS flash
state_get(key [, default])str | nilRead string from NVS flash
state_remove(key)β€”Delete key from NVS flash
millis()intMilliseconds since boot
print(msg)β€”Log to web UI panel
str(v)stringConvert any value to string
format(fmt, ...)stringC-style string formatting

Renamed or rewritten by the preprocessor (the call you write is replaced at compile time β€” writing them without a DBC loaded will produce errors):

| Source (what you write) | Becomes (after preprocessing) | |---|---|---| | msg_sig_set(draft, "sig", val) | msg_sig_set(draft, sb, len, be, signed, scale, offset, val) β€” full encode with scale/offset | | msg_sig_get(draft, "sig") | msg_sig_get(draft, sb, len, be, signed, scale, offset) β€” full decode with scale/offset | | can_msg_send(bus, draft) | can_send_raw(bus, draft["id"], draft["data"]) β€” ID resolved from draft |

Low-level inline forms (generated by the preprocessor β€” do not write these manually)

These are emitted automatically when the preprocessor rewrites DBC-aware calls. You'll see them if you inspect a .bep file, but you should never type them into a .be source file.


Berry lang syntax cheat sheet

var x = 0                        # variable
x += 1                           # compound assignment

def greet(name)                  # function definition
  print("hi " .. name)
end

if x > 10                        # if / elif / else / end
  print("big")
elif x > 5
  print("medium")
else
  print("small")
end

for i: 0..4                      # range loop (inclusive on both ends)
  print(str(i))
end

while x > 0                      # while loop
  x -= 1
end

var fn = /-> print("lambda")     # zero-arg lambda
var fn2 = def(a) a * 2 end       # inline function expression

var b = bytes(-8)                 # fixed 8-byte buffer, zero-filled
b.setbits(24, 2, 2)               # signal at bit 24, 2 bits, value 2 (PRESENT)
var b2 = bytes('deadbeef0011')    # 8 bytes from hex string
b.item(0)                         # read by index

setbits

Writes a bit-level value into a bytes buffer. Chainable β€” returns self.

Works natively with Intel (little-endian) unsigned DBC signals β€” the most common format. For Motorola (big-endian) or signed signals, convert offset/value before calling.

ParameterUnitRange
offset_bitsbits0+
len_bitsbits1..32
valintegeruses only lower len_bits
# arbitrary bit offset, no need for byte alignment
can_send_raw(0, 0x273, bytes(-8).setbits(24, 2, 2))

More here - Berry-in-20-minutes