- CitrineOS core extracted (CSMS OCPP 2.0.1) - OpenOCPP extracted (firmware OCPP 1.6J/2.0.1) - ShapeShifter library installed (pip install -e) - ShapeShifter specification extracted - EVerest extracted TODO updated with progress
533 lines
18 KiB
Lua
533 lines
18 KiB
Lua
-- InfyPower V1.13 CAN protocol dissector (Lua)
|
|
-- SPDX-License-Identifier: Apache-2.0
|
|
--
|
|
-- Install:
|
|
-- mkdir -p ~/.local/lib/wireshark/plugins
|
|
-- cp infypower_can.lua ~/.local/lib/wireshark/plugins/
|
|
-- Reload: Analyze -> Reload Lua Plugins
|
|
--
|
|
-- Display filters:
|
|
-- infypower.src == 240 (controller 0xF0)
|
|
-- infypower.dest == 0 (module 0)
|
|
-- infypower.command == 0x03
|
|
-- infypower.src == 0 && infypower.dest == 240 (module -> controller)
|
|
-- infypower.internal (cmd 0x17 -> internal controller, default 0xF8)
|
|
-- infypower.command == 0x17 && infypower.dest == 248
|
|
-- infypower.pair contains "req" or "resp" (add custom column in Wireshark)
|
|
--
|
|
-- Optional row color: View -> Coloring Rules -> Import -> infypower_colorfilters
|
|
--
|
|
-- Request/response pairing uses FIFO order per (module, command). Reload once
|
|
-- after opening a capture so request frames also show response links.
|
|
|
|
local infypower_proto = Proto("infypower", "InfyPower V1.13 CAN")
|
|
|
|
infypower_proto.prefs.controller_addr = Pref.uint(
|
|
"Controller address (decimal)", 240, "Default EVerest controller: 0xF0")
|
|
infypower_proto.prefs.internal_controller_addr = Pref.uint(
|
|
"Internal controller address (decimal)", 248,
|
|
"Destination for internal cmd 0x17 traffic (default 0xF8)")
|
|
infypower_proto.prefs.highlight_internal = Pref.bool(
|
|
"Highlight internal cmd 0x17 frames", true,
|
|
"Label and colorize cmd 0x17 frames to internal controller")
|
|
|
|
-- Header fields (derived from 29-bit CAN ID, not from payload bytes)
|
|
local f_err = ProtoField.uint8("infypower.error_code", "Error Code", base.HEX)
|
|
local f_dev = ProtoField.uint8("infypower.device_number", "Device Number", base.HEX)
|
|
local f_cmd = ProtoField.uint8("infypower.command", "Command", base.HEX)
|
|
local f_dst = ProtoField.uint8("infypower.dest", "Destination Address", base.HEX)
|
|
local f_src = ProtoField.uint8("infypower.src", "Source Address", base.HEX)
|
|
|
|
-- Payload fields
|
|
local f_voltage = ProtoField.float("infypower.voltage_v", "Voltage (V)")
|
|
local f_current = ProtoField.float("infypower.current_a", "Current (A)")
|
|
local f_v_ext = ProtoField.float("infypower.v_ext_v", "External Voltage (V)")
|
|
local f_i_avail = ProtoField.float("infypower.i_avail_a", "Available Current (A)")
|
|
local f_max_v = ProtoField.uint16("infypower.max_voltage_v", "Max Voltage (V)", base.DEC)
|
|
local f_min_v = ProtoField.uint16("infypower.min_voltage_v", "Min Voltage (V)", base.DEC)
|
|
local f_max_i = ProtoField.float("infypower.max_current_a", "Max Current (A)")
|
|
local f_rated_pwr = ProtoField.uint32("infypower.rated_power_w", "Rated Power (W)", base.DEC)
|
|
local f_module_count = ProtoField.uint8("infypower.module_count", "Module Count", base.DEC)
|
|
local f_group_no = ProtoField.uint8("infypower.group_no", "Group Number", base.DEC)
|
|
local f_ambient_temp = ProtoField.int8("infypower.ambient_temp_c", "Ambient Temp (C)", base.DEC)
|
|
local f_voltage_mv = ProtoField.uint32("infypower.voltage_mv", "Voltage (mV)", base.DEC)
|
|
local f_current_ma = ProtoField.uint32("infypower.current_ma", "Current (mA)", base.DEC)
|
|
local f_on = ProtoField.bool("infypower.on", "Output On")
|
|
local f_raw = ProtoField.bytes("infypower.raw", "Raw Payload")
|
|
-- Wireshark draws request/response arrows in the packet list when these are set
|
|
local f_response_in = ProtoField.framenum(
|
|
"infypower.response_in", "Response in", base.NONE, frametype.RESPONSE)
|
|
local f_request_in = ProtoField.framenum(
|
|
"infypower.request_in", "Request in", base.NONE, frametype.REQUEST)
|
|
local f_pair = ProtoField.string("infypower.pair", "Pairing")
|
|
local f_internal = ProtoField.bool(
|
|
"infypower.internal", "Internal communication (cmd 0x17 to internal controller)")
|
|
|
|
infypower_proto.fields = {
|
|
f_err, f_dev, f_cmd, f_dst, f_src,
|
|
f_voltage, f_current, f_v_ext, f_i_avail,
|
|
f_max_v, f_min_v, f_max_i, f_rated_pwr,
|
|
f_module_count, f_group_no, f_ambient_temp,
|
|
f_voltage_mv, f_current_ma, f_on, f_raw,
|
|
f_response_in, f_request_in, f_pair, f_internal,
|
|
}
|
|
|
|
local can_id_field = Field.new("can.id")
|
|
|
|
-- Forward declarations (Listener callback must see these as locals, not globals)
|
|
local parse_can_id
|
|
local is_infypower_frame
|
|
local get_can_id
|
|
|
|
local CMD_NAMES = {
|
|
[0x02] = "ReadModuleCount",
|
|
[0x03] = "ReadModuleVI",
|
|
[0x04] = "PowerModuleStatus",
|
|
[0x0A] = "ReadModuleCapabilities",
|
|
[0x0B] = "ReadModuleBarcode",
|
|
[0x0C] = "ReadModuleVIAfterDiode",
|
|
[0x17] = "InternalComm",
|
|
[0x1A] = "SetModuleOnOff",
|
|
[0x1C] = "SetModuleVI",
|
|
}
|
|
|
|
local function field_uint(field)
|
|
if field == nil then
|
|
return nil
|
|
end
|
|
local v = field.value
|
|
if type(v) == "userdata" then
|
|
return tonumber(tostring(v))
|
|
end
|
|
return tonumber(v)
|
|
end
|
|
|
|
get_can_id = function()
|
|
local id_f = can_id_field()
|
|
if id_f == nil then
|
|
return nil
|
|
end
|
|
return field_uint(id_f)
|
|
end
|
|
|
|
parse_can_id = function(can_id)
|
|
local id = bit.band(can_id, 0x1FFFFFFF)
|
|
return bit.band(id, 0xFF),
|
|
bit.band(bit.rshift(id, 8), 0xFF),
|
|
bit.band(bit.rshift(id, 16), 0x3F),
|
|
bit.band(bit.rshift(id, 22), 0x0F),
|
|
bit.band(bit.rshift(id, 26), 0x07)
|
|
end
|
|
|
|
local function internal_controller_addr()
|
|
return infypower_proto.prefs.internal_controller_addr
|
|
end
|
|
|
|
local function is_internal_frame(src, dst, cmd)
|
|
return cmd == 0x17 and dst == internal_controller_addr()
|
|
end
|
|
|
|
is_infypower_frame = function(can_id)
|
|
if can_id == nil or can_id <= 0x7FF then
|
|
return false
|
|
end
|
|
local _, dst, cmd, dev = parse_can_id(can_id)
|
|
if is_internal_frame(nil, dst, cmd) then
|
|
return true
|
|
end
|
|
return (dev == 0x0A or dev == 0x0B) and CMD_NAMES[cmd] ~= nil
|
|
end
|
|
|
|
-- Request/response pairing state (filled by tap, read during dissection)
|
|
local pair_peer = {} -- frame number -> peer frame number
|
|
local pair_role = {} -- frame number -> "request" | "response"
|
|
local pending = {} -- pending[queue_key][cmd] = { frame, ... }
|
|
local last_group_req = {} -- last_group_req[cmd] = frame (for group reads, device 0x0B)
|
|
local tap_retap_done = false
|
|
local pairing_retap_in_progress = false -- guard: retap_packets() calls reset()
|
|
|
|
local function controller_addr()
|
|
return infypower_proto.prefs.controller_addr
|
|
end
|
|
|
|
local function pending_key_module(addr)
|
|
return "mod:" .. addr
|
|
end
|
|
|
|
local function pending_key_group(addr)
|
|
return "grp:" .. addr
|
|
end
|
|
|
|
local function pending_queue(key, cmd)
|
|
if pending[key] == nil then
|
|
pending[key] = {}
|
|
end
|
|
if pending[key][cmd] == nil then
|
|
pending[key][cmd] = {}
|
|
end
|
|
return pending[key][cmd]
|
|
end
|
|
|
|
local function link_pair(req_frame, resp_frame)
|
|
pair_peer[req_frame] = resp_frame
|
|
pair_peer[resp_frame] = req_frame
|
|
pair_role[req_frame] = "request"
|
|
pair_role[resp_frame] = "response"
|
|
end
|
|
|
|
local function is_request_frame(src, dst)
|
|
return src == controller_addr() and dst ~= controller_addr()
|
|
end
|
|
|
|
local function is_response_frame(src, dst)
|
|
return dst == controller_addr() and src ~= controller_addr()
|
|
end
|
|
|
|
local function pair_reset()
|
|
-- Reassign tables (avoid pairs() during Wireshark reset; also clears nested queues)
|
|
pair_peer = {}
|
|
pair_role = {}
|
|
pending = {}
|
|
last_group_req = {}
|
|
-- retap_packets() invokes reset(); do not re-arm retap in that case
|
|
if not pairing_retap_in_progress then
|
|
tap_retap_done = false
|
|
end
|
|
end
|
|
|
|
local function pair_process_frame(pinfo, can_id)
|
|
local src, dst, cmd, dev = parse_can_id(can_id)
|
|
if not CMD_NAMES[cmd] or is_internal_frame(src, dst, cmd) then
|
|
return
|
|
end
|
|
|
|
local fnum = pinfo.number
|
|
|
|
if is_request_frame(src, dst) then
|
|
if dev == 0x0B then
|
|
last_group_req[cmd] = fnum
|
|
table.insert(pending_queue(pending_key_group(dst), cmd), fnum)
|
|
else
|
|
table.insert(pending_queue(pending_key_module(dst), cmd), fnum)
|
|
end
|
|
elseif is_response_frame(src, dst) then
|
|
local req_frame
|
|
|
|
local mod_queue = pending_queue(pending_key_module(src), cmd)
|
|
if #mod_queue > 0 then
|
|
req_frame = table.remove(mod_queue, 1)
|
|
elseif last_group_req[cmd] ~= nil then
|
|
req_frame = last_group_req[cmd]
|
|
else
|
|
-- try any pending group queue entry for this cmd
|
|
for _, grp_queue in pairs(pending) do
|
|
if grp_queue[cmd] and #grp_queue[cmd] > 0 then
|
|
req_frame = table.remove(grp_queue[cmd], 1)
|
|
break
|
|
end
|
|
end
|
|
end
|
|
|
|
if req_frame ~= nil then
|
|
link_pair(req_frame, fnum)
|
|
end
|
|
end
|
|
end
|
|
|
|
local function add_pairing_to_tree(subtree, pinfo)
|
|
local fnum = pinfo.number
|
|
local peer = pair_peer[fnum]
|
|
if peer == nil then
|
|
return
|
|
end
|
|
|
|
local role = pair_role[fnum]
|
|
if role == "request" then
|
|
subtree:add(f_response_in, peer):set_text("Response in frame: " .. peer)
|
|
subtree:add(f_pair, "req"):set_generated()
|
|
pinfo.cols.info:append(string.format(" \xE2\x86\x92 #%d", peer))
|
|
elseif role == "response" then
|
|
subtree:add(f_request_in, peer):set_text("Request in frame: " .. peer)
|
|
subtree:add(f_pair, "resp"):set_generated()
|
|
pinfo.cols.info:append(string.format(" \xE2\x86\x90 #%d", peer))
|
|
end
|
|
end
|
|
|
|
local DEVICE_NAMES = {
|
|
[0x0A] = "SingleModule",
|
|
[0x0B] = "GroupModule",
|
|
}
|
|
|
|
local STATUS0_BITS = {
|
|
[0] = "output_short_current",
|
|
[4] = "sleeping",
|
|
[5] = "discharge_abnormal",
|
|
}
|
|
|
|
local STATUS1_BITS = {
|
|
[0] = "dc_side_off",
|
|
[1] = "fault_alarm",
|
|
[2] = "protection_alarm",
|
|
[3] = "fan_fault_alarm",
|
|
[4] = "over_temperature_alarm",
|
|
[5] = "output_over_voltage_alarm",
|
|
[6] = "walk_in_enable",
|
|
[7] = "communication_interrupt_alarm",
|
|
}
|
|
|
|
local STATUS2_BITS = {
|
|
[0] = "power_limit_status",
|
|
[1] = "id_repeat_alarm",
|
|
[2] = "load_sharing_alarm",
|
|
[3] = "input_phase_lost_alarm",
|
|
[4] = "input_unbalanced_alarm",
|
|
[5] = "input_low_voltage_alarm",
|
|
[6] = "input_over_voltage_protection",
|
|
[7] = "pfc_side_off",
|
|
}
|
|
|
|
local function be16(buf, offset)
|
|
if buf:len() < offset + 2 then
|
|
return 0
|
|
end
|
|
return buf(offset, 2):uint()
|
|
end
|
|
|
|
local function be32(buf, offset)
|
|
if buf:len() < offset + 4 then
|
|
return 0
|
|
end
|
|
return buf(offset, 4):uint()
|
|
end
|
|
|
|
local function tvb_float_be(buf, offset)
|
|
if buf:len() < offset + 4 then
|
|
return nil
|
|
end
|
|
return buf(offset, 4):float()
|
|
end
|
|
|
|
local function format_addr(addr)
|
|
return string.format("0x%02X", addr)
|
|
end
|
|
|
|
-- Add a field parsed from CAN ID (not from tvb) so it shows in the tree and filters work
|
|
local function add_header_field(tree, field, value, label_fmt, ...)
|
|
local ti = tree:add(field, value)
|
|
ti:set_generated()
|
|
if label_fmt then
|
|
ti:set_text(string.format(label_fmt, ...))
|
|
end
|
|
return ti
|
|
end
|
|
|
|
local function add_status_bits(tree, title, byte_val, bit_map)
|
|
local bits_tree = tree:add(infypower_proto, title .. string.format(" (0x%02X)", byte_val))
|
|
bits_tree:set_generated()
|
|
for bitpos, name in pairs(bit_map) do
|
|
if bit.band(byte_val, bit.lshift(1, bitpos)) ~= 0 then
|
|
local ti = bits_tree:add(infypower_proto, name .. ": set")
|
|
ti:set_generated()
|
|
end
|
|
end
|
|
end
|
|
|
|
local function payload_is_read_request(buf)
|
|
if buf:len() < 8 then
|
|
return false
|
|
end
|
|
for i = 0, 7 do
|
|
if buf(i, 1):uint() ~= 0 then
|
|
return false
|
|
end
|
|
end
|
|
return true
|
|
end
|
|
|
|
local function is_controller_to_module(src, dst)
|
|
local ctrl = controller_addr()
|
|
return src == ctrl and dst ~= ctrl
|
|
end
|
|
|
|
local function dissect_payload(buf, cmd, tree, src, dst)
|
|
local payload_tree = tree:add(infypower_proto, buf(), "Payload")
|
|
payload_tree:set_generated()
|
|
|
|
if buf:len() == 0 then
|
|
local ti = payload_tree:add(infypower_proto, "No payload bytes in dissector buffer")
|
|
ti:set_generated()
|
|
return
|
|
end
|
|
|
|
payload_tree:add(f_raw, buf())
|
|
|
|
if buf:len() < 8 then
|
|
local ti = payload_tree:add(infypower_proto,
|
|
string.format("Short payload (%d bytes, expected 8)", buf:len()))
|
|
ti:set_generated()
|
|
return
|
|
end
|
|
|
|
if payload_is_read_request(buf) and cmd ~= 0x1A and cmd ~= 0x1C then
|
|
if is_controller_to_module(src, dst) then
|
|
local ti = payload_tree:add(infypower_proto, "Read request (8 zero bytes)")
|
|
ti:set_generated()
|
|
else
|
|
local ti = payload_tree:add(infypower_proto, "Response with zero payload")
|
|
ti:set_generated()
|
|
end
|
|
return
|
|
end
|
|
|
|
if cmd == 0x03 then
|
|
local v = tvb_float_be(buf, 0)
|
|
local c = tvb_float_be(buf, 4)
|
|
if v ~= nil then
|
|
payload_tree:add(f_voltage, v):set_text(string.format("Output Voltage: %.3f V", v))
|
|
end
|
|
if c ~= nil then
|
|
payload_tree:add(f_current, c):set_text(string.format("Output Current: %.3f A", c))
|
|
end
|
|
elseif cmd == 0x0C then
|
|
payload_tree:add(f_v_ext, be16(buf, 0) * 0.1)
|
|
:set_text(string.format("V_ext: %.1f V", be16(buf, 0) * 0.1))
|
|
payload_tree:add(f_i_avail, be16(buf, 2) * 0.1)
|
|
:set_text(string.format("I_avail: %.1f A", be16(buf, 2) * 0.1))
|
|
elseif cmd == 0x0A then
|
|
payload_tree:add(f_max_v, be16(buf, 0))
|
|
payload_tree:add(f_min_v, be16(buf, 2))
|
|
payload_tree:add(f_max_i, be16(buf, 4) * 0.1)
|
|
:set_text(string.format("Max Current: %.1f A", be16(buf, 4) * 0.1))
|
|
payload_tree:add(f_rated_pwr, be16(buf, 6) * 10)
|
|
:set_text(string.format("Rated Power: %d W", be16(buf, 6) * 10))
|
|
elseif cmd == 0x02 then
|
|
payload_tree:add(f_module_count, buf(2, 1))
|
|
:set_text("Module Count: " .. buf(2, 1):uint())
|
|
elseif cmd == 0x1A then
|
|
local on = buf(0, 1):uint() == 0
|
|
payload_tree:add(f_on, on):set_text(on and "Output: ON" or "Output: OFF")
|
|
elseif cmd == 0x1C then
|
|
local mv = be32(buf, 0)
|
|
local ma = be32(buf, 4)
|
|
payload_tree:add(f_voltage_mv, mv)
|
|
:set_text(string.format("Voltage: %d mV (%.3f V)", mv, mv / 1000))
|
|
payload_tree:add(f_current_ma, ma)
|
|
:set_text(string.format("Current: %d mA (%.3f A)", ma, ma / 1000))
|
|
elseif cmd == 0x04 then
|
|
payload_tree:add(f_group_no, buf(2, 1))
|
|
:set_text("Group Number: " .. buf(2, 1):uint())
|
|
local amb = buf(4, 1):int()
|
|
payload_tree:add(f_ambient_temp, amb)
|
|
:set_text(string.format("Ambient Temp: %d C", amb))
|
|
add_status_bits(payload_tree, "Module State 0", buf(7, 1):uint(), STATUS0_BITS)
|
|
add_status_bits(payload_tree, "Module State 1", buf(6, 1):uint(), STATUS1_BITS)
|
|
add_status_bits(payload_tree, "Module State 2", buf(5, 1):uint(), STATUS2_BITS)
|
|
elseif cmd == 0x0B then
|
|
local ti = payload_tree:add(infypower_proto,
|
|
string.format("Barcode byte 0 (char): '%c' (0x%02X)", buf(0, 1):uint(), buf(0, 1):uint()))
|
|
ti:set_generated()
|
|
ti = payload_tree:add(f_raw, buf(1, 7))
|
|
ti:set_text("Barcode encoding: " .. buf(1, 7):bytes():tohex())
|
|
elseif cmd == 0x17 then
|
|
local ti = payload_tree:add(infypower_proto,
|
|
string.format("Byte0: 0x%02X Byte1: 0x%02X Byte2-3: 0x%04X Byte4-5: 0x%04X",
|
|
buf(0, 1):uint(), buf(1, 1):uint(), be16(buf, 2), be16(buf, 4)))
|
|
ti:set_generated()
|
|
ti = payload_tree:add(infypower_proto, "Bytes 6-7: " .. buf(6, 2):bytes():tohex())
|
|
ti:set_generated()
|
|
else
|
|
local ti = payload_tree:add(infypower_proto, "Payload not decoded for this command")
|
|
ti:set_generated()
|
|
end
|
|
end
|
|
|
|
local function highlight_internal_row(pinfo)
|
|
if not infypower_proto.prefs.highlight_internal then
|
|
return
|
|
end
|
|
if Color == nil then
|
|
return
|
|
end
|
|
-- Light amber background in packet list (Wireshark 4.x)
|
|
pinfo.cols.bg = Color.new(65535, 0xFFEE, 0xAA00)
|
|
pinfo.cols.fg = Color.new(0, 0, 0)
|
|
end
|
|
|
|
local function dissect_infypower(buf, pinfo, tree, can_id)
|
|
local src, dst, cmd, dev, err = parse_can_id(can_id)
|
|
local internal = is_internal_frame(src, dst, cmd)
|
|
|
|
if internal then
|
|
pinfo.cols.protocol:set("InfyPower-INT")
|
|
highlight_internal_row(pinfo)
|
|
else
|
|
pinfo.cols.protocol:set("InfyPower")
|
|
end
|
|
pinfo.cols.src:set(format_addr(src))
|
|
pinfo.cols.dst:set(format_addr(dst))
|
|
local subtree = tree:add(infypower_proto, buf(),
|
|
internal and "InfyPower V1.13 (internal)" or "InfyPower V1.13")
|
|
|
|
add_header_field(subtree, f_src, src, "Source Address: 0x%02X (%d)", src, src)
|
|
add_header_field(subtree, f_dst, dst, "Destination Address: 0x%02X (%d)", dst, dst)
|
|
add_header_field(subtree, f_cmd, cmd, "Command: 0x%02X (%s)", cmd, CMD_NAMES[cmd] or "unknown")
|
|
add_header_field(subtree, f_dev, dev, "Device Number: 0x%X (%s)", dev, DEVICE_NAMES[dev] or "unknown")
|
|
add_header_field(subtree, f_err, err, "Error Code: 0x%X", err)
|
|
if internal then
|
|
subtree:add(f_internal, true):set_text("Internal communication: cmd 0x17 -> internal controller")
|
|
end
|
|
|
|
local info_prefix = internal and "[INTERNAL 0x17] " or ""
|
|
pinfo.cols.info:set(string.format("%s%s %02X->%02X cmd=0x%02X",
|
|
info_prefix, CMD_NAMES[cmd] or "Infy", src, dst, cmd))
|
|
|
|
dissect_payload(buf, cmd, subtree, src, dst)
|
|
add_pairing_to_tree(subtree, pinfo)
|
|
|
|
return buf:len()
|
|
end
|
|
|
|
function infypower_proto.dissector(buf, pinfo, tree)
|
|
local can_id = get_can_id()
|
|
if not is_infypower_frame(can_id) then
|
|
return 0
|
|
end
|
|
return dissect_infypower(buf, pinfo, tree, can_id)
|
|
end
|
|
|
|
local function infypower_heuristic(buf, pinfo, tree)
|
|
local can_id = get_can_id()
|
|
if not is_infypower_frame(can_id) then
|
|
return false
|
|
end
|
|
dissect_infypower(buf, pinfo, tree, can_id)
|
|
return true
|
|
end
|
|
|
|
infypower_proto:register_heuristic("can", infypower_heuristic)
|
|
|
|
-- Pass 1: walk frames in order and build request/response pairs (FIFO per module+cmd)
|
|
local infy_pair_tap = Listener.new(nil, nil)
|
|
|
|
function infy_pair_tap.packet(pinfo, tvb)
|
|
local can_id = get_can_id()
|
|
if is_infypower_frame(can_id) then
|
|
pair_process_frame(pinfo, can_id)
|
|
end
|
|
end
|
|
|
|
function infy_pair_tap.reset()
|
|
pair_reset()
|
|
end
|
|
|
|
function infy_pair_tap.draw()
|
|
-- Pass 2: redissect so request frames also get Response-in links
|
|
if tap_retap_done or retap_packets == nil then
|
|
return
|
|
end
|
|
tap_retap_done = true
|
|
pairing_retap_in_progress = true
|
|
retap_packets()
|
|
pairing_retap_in_progress = false
|
|
end
|