CAN Features
CAN bus works differently from Modbus or serial communication interface. If you are writing your first CAN blueprint after experience with Modbus, several behaviors are likely to surprise you.
CAN hardware ports must be configured before your blueprint can connect to them. See Hardware Ports for setup instructions.
CAN Is Publish-Subscribe, Not Request-Response
With Modbus, you actively request data: you call read_holdings() and receive a response. CAN works the opposite way. Devices on a CAN bus broadcast messages on their own schedule — your blueprint does not ask for data, it listens for it.
To receive CAN messages, you first declare which message IDs you are interested in by creating a monitor or a queue at connection time. The CAN hardware then buffers incoming messages matching those IDs. When your telemetry cycle runs, you collect the buffered data with pop().
This is why the connection setup in reconnect() creates a monitor in addition to the client — both are part of establishing a working CAN subscription:
function reconnect()
if can_client then return end
-- ...read config, create client...
local client, client_err = can.new(conn_cfg.conn_str)
if client_err then
enapter.log('CAN connect: ' .. client_err, 'error')
return
end
can_client = client
-- Declare which message IDs we want to receive
local monitor, mon_err = can_client:monitor({ 0x400, 0x401 })
if mon_err then
enapter.log('CAN monitor: ' .. mon_err, 'error')
can_client = nil
return
end
can_monitor = monitor
end
Monitor vs Queue: Choosing the Right Reception Mode
The CAN library provides two ways to buffer incoming messages:
client:monitor(ids) — stores only the most recent value per message ID. Each call to monitor:pop() returns the latest received message and then clears the buffer. Use this when your telemetry only needs the current state of the device — which covers most cases.
client:queue(ids, size, policy) — stores all received messages up to a configurable limit per ID. Use this when your logic needs to process every message, not just the latest — for example, counting events or logging sequences.
When creating a queue, you must also choose a drop policy for when the queue is full:
can.DROP_OLDEST— discards the oldest message to make room for the new one. Use this when freshness matters and you always want the latest data.can.DROP_NEWEST— discards the incoming message. Use this when you cannot afford to lose the oldest messages already queued.
-- Monitor: only the latest value per ID
local monitor, err = can_client:monitor({ 0x400, 0x401 })
-- Queue: all messages up to 10 per ID, drop oldest when full
local queue, err = can_client:queue({ 0x400, 0x401 }, 10, can.DROP_OLDEST)
Getting Values
Every call to pop() clears the buffer for the requested IDs. If no new message for a given ID has arrived since the last pop(), the corresponding result entry will be nil.
This is normal and expected — it just means the device has not sent a new message for that ID yet. It is not an error.
The error return value and the per-ID nil are separate things:
local data, err = can_monitor:pop({ 0x400, 0x401 })
-- err means a real failure (e.g. the connection broke)
if err then
enapter.log('CAN read: ' .. err, 'error')
can_client = nil
can_monitor = nil
enapter.send_telemetry({ conn_alerts = {'communication_failed'} })
return
end
-- data[1] and data[2] may each independently be nil
-- if the device hasn't sent a new message since the last pop()
local value_400 = data[1] -- nil if no new 0x400 message
local value_401 = data[2] -- nil if no new 0x401 message
if value_400 then
-- process value_400
end
A common mistake is to treat data[i] == nil as a communication failure and reset the connection. This causes unnecessary reconnection churn whenever the device sends messages less frequently than your telemetry cycle.
Reset Both Client and Monitor Together
Unlike Modbus where you only track a single modbus_client, a CAN connection has two related pieces of state: the client and the monitor (or queue). Both must be tracked as global variables, and both must be reset to nil whenever either one fails — otherwise reconnect() will see can_client ~= nil and assume everything is fine.
local can_client = nil
local can_monitor = nil -- must also be nil-checked and reset
-- Reset both in the configuration change callback
configuration.after_write('connection', function()
can_client = nil
can_monitor = nil
end)
When resetting on error, reset both:
local data, err = can_monitor:pop({ 0x400, 0x401 })
if err then
can_client = nil -- triggers reconnect()
can_monitor = nil -- clears the stale subscription
enapter.send_telemetry({ conn_alerts = {'communication_failed'} })
return
end
If you reset only can_client but leave can_monitor set, send_telemetry() will continue calling pop() on a monitor that belongs to a closed connection, leading to misleading errors or stale data.
Result Indexing Follows the ID List Order
pop() returns a table where each element corresponds positionally to the ID list you pass in. data[1] is the result for the first ID, data[2] for the second, and so on.
local data, err = can_monitor:pop({ 0x400, 0x401, 0x402 })
-- [1] [2] [3]
local voltage = data[1] -- 0x400
local current = data[2] -- 0x401
local power = data[3] -- 0x402
Getting the order wrong silently maps the wrong message to the wrong telemetry field — there is no runtime error to catch it. Always verify that the order in your pop() call matches the order in which you assign the results.
It is good practice to keep the ID list in one place and reference the same list in both monitor() and pop():
local MONITORED_IDS = { 0x400, 0x401, 0x402 }
-- In reconnect():
can_monitor = can_client:monitor(MONITORED_IDS)
-- In send_telemetry():
local data, err = can_monitor:pop(MONITORED_IDS)
local voltage = data[1]
local current = data[2]
local power = data[3]
This makes it immediately obvious if the list ever changes and an assignment is missed.