[#102763] external-control: Add CAN support
diff --git a/src/Infrastructure b/src/Infrastructure
index a02d8e0..a0fcf54 160000
--- a/src/Infrastructure
+++ b/src/Infrastructure
@@ -1 +1 @@
-Subproject commit a02d8e0fbfff1f081ecc8cd0b2ff1d2f7d4bf2b6
+Subproject commit a0fcf54f9a31591e607e75143b796aeb4da626b8
diff --git a/src/Renode/Network/ExternalControl/CANBus.cs b/src/Renode/Network/ExternalControl/CANBus.cs
new file mode 100644
index 0000000..1b33fc8
--- /dev/null
+++ b/src/Renode/Network/ExternalControl/CANBus.cs
@@ -0,0 +1,106 @@
+//
+// Copyright (c) 2010-2026 Antmicro
+//
+// This file is licensed under the MIT License.
+// Full license text is available in 'licenses/MIT.txt'.
+//
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.InteropServices;
+
+using Antmicro.Renode.Core;
+using Antmicro.Renode.Core.CAN;
+using Antmicro.Renode.Logging;
+using Antmicro.Renode.Peripherals;
+using Antmicro.Renode.Peripherals.CAN;
+using Antmicro.Renode.Utilities;
+
+namespace Antmicro.Renode.Network.ExternalControl;
+
+public class CANBus(ExternalControlServer parent) : BaseCommand(parent), IInstanceBasedCommand<CANExternalControlBus>, IEmulationElement
+{
+    public static int MaximumCanFrameSize = 64;
+
+    public MessagePayload Invoke(CANExternalControlBus instance, List<byte> data)
+    {
+        if(data.Count < 1)
+        {
+            return MessagePayload.Error(Identifier, $"Expected at least {1 + InstanceBasedCommandHeaderSize} bytes of payload");
+        }
+        var command = (CANBusCommands)data[0];
+        Logger.DebugLog(instance, "Received {0} command", command);
+
+        switch(command)
+        {
+        case CANBusCommands.SendFrame:
+            this.DebugLog("Received SendFrameCommand with data: {0}", data.Select(x => x.ToString("02x")).Stringify());
+
+            var data_array = data.ToArray();
+            var packet_length = Misc.ByteArrayRead(1, data_array);
+            var id = Misc.ByteArrayRead(5, data_array);
+
+            if(data.Count != packet_length + 9)
+            {
+                return MessagePayload.Error(Identifier, $"Incorrect number of bytes in payload. Expected {packet_length + 9}");
+            }
+
+            var packet = data.Skip(9).ToArray();
+
+            instance.SendFrame(packet, id);
+            break;
+
+        case CANBusCommands.RegisterCallbacks:
+            var ed = (int) Misc.ByteArrayRead(1, data.ToArray());
+            Logger.DebugLog(this, "Attaching ReceivedMessage callback to instance '{0}'", instance.GetName());
+            instance.ReceivedMessage += (frame) => ReceivedFrame(frame, ed);
+            break;
+
+        default:
+            return MessagePayload.Error(Identifier, "Unexpected command format");
+        }
+        return MessagePayload.Success(Identifier);
+    }
+
+    public void ReceivedFrame(CANMessageFrame message, int callbackIndex)
+    {
+        var eventHeader = EventHeader.Create(EmulationManager.Instance.CurrentEmulation.MasterTimeSource.ElapsedVirtualTime.TotalNanoseconds, message);
+
+        var response = parent.SendRequest(MessagePayload.Event(Identifier, callbackIndex, eventHeader, message.Data));
+        LogOnErrorResponse(response);
+    }
+
+    public override MessagePayload Invoke(List<byte> data) => this.InvokeHandledWithInstance(data);
+
+    public override Command Identifier => Command.CANBus;
+
+    public InstanceCollection<CANExternalControlBus> Instances { get; } = new InstanceCollection<CANExternalControlBus>();
+
+    private const int InstanceBasedCommandHeaderSize = IInstanceBasedCommandExtensions.HeaderSize;
+
+    [StructLayout(LayoutKind.Sequential, Pack = 1)]
+    public struct EventHeader
+    {
+        public static EventHeader Create(ulong timestamp, CANMessageFrame message)
+        {
+            var len = message.Data.Length;
+            if(len > MaximumCanFrameSize)
+            {
+                throw new ArgumentOutOfRangeException();
+            }
+            return new EventHeader() { TimestampNanoseconds = timestamp, Id = message.Id, PacketLength = len };
+        }
+
+        public ulong TimestampNanoseconds;
+
+        public int PacketLength;
+
+        public uint Id;
+    }
+
+    public enum CANBusCommands : byte
+    {
+        SendFrame = 0,
+        RegisterCallbacks = 2,
+    }
+}
diff --git a/src/Renode/Network/ExternalControl/ExternalControlServer.cs b/src/Renode/Network/ExternalControl/ExternalControlServer.cs
index 6ec70ed..910603a 100644
--- a/src/Renode/Network/ExternalControl/ExternalControlServer.cs
+++ b/src/Renode/Network/ExternalControl/ExternalControlServer.cs
@@ -141,6 +141,7 @@
             commandHandlers.Register(new GPIOPort(this));
             commandHandlers.Register(new SystemBus(this));
             commandHandlers.Register(new CheckVersion(this));
+            commandHandlers.Register(new CANBus(this));
 
             var getMachineHandler = new GetMachine(this);
             Machines = getMachineHandler;
diff --git a/src/Renode/Network/ExternalControl/ICommand.cs b/src/Renode/Network/ExternalControl/ICommand.cs
index d7843a7..95278e7 100644
--- a/src/Renode/Network/ExternalControl/ICommand.cs
+++ b/src/Renode/Network/ExternalControl/ICommand.cs
@@ -24,6 +24,7 @@
         SystemBus,
         TimeElapsedCallback,
         SPI,
+        CANBus,
     }
 
     public interface ICommand
diff --git a/src/Renode/Network/ExternalControl/MessagePayload.cs b/src/Renode/Network/ExternalControl/MessagePayload.cs
index 772342a..48e461b 100644
--- a/src/Renode/Network/ExternalControl/MessagePayload.cs
+++ b/src/Renode/Network/ExternalControl/MessagePayload.cs
@@ -51,6 +51,14 @@
             return new MessagePayload(command, CommandType.EventRequest, BitConverter.GetBytes(eventId).Concat(payload.AsRawBytes()).ToArray());
         }
 
+        public static MessagePayload Event<T>(Command command, int eventId, T payloadHeader, byte[] data) where T : struct
+        {
+            return new MessagePayload(command, CommandType.EventRequest,
+            BitConverter.GetBytes(eventId)
+                .Concat(payloadHeader.AsRawBytes())
+                .Concat(data).ToArray());
+        }
+
         public MessagePayload(Command cmd, CommandType type, byte[] data)
         {
             Command = cmd;
diff --git a/tests/external-control/external-control.robot b/tests/external-control/external-control.robot
index 8177dd8..533e2ea 100644
--- a/tests/external-control/external-control.robot
+++ b/tests/external-control/external-control.robot
@@ -6,6 +6,7 @@
 
 *** Variables ***
 ${EXTERNAL_CONTROL_DIR}             ${CURDIR}/../../tools/external_control_client
+${CREATE_SNAPSHOT_ON_FAIL}          False
 # This will be overriden by the test setup
 ${BUILD_DIR}                        ${EXTERNAL_CONTROL_DIR}/build
 ${PORT}                             3344
@@ -335,3 +336,26 @@
     END
 
     Terminate Process               ${proc}
+
+Should Run CAN Sample
+    [Tags]                         exclude_windows
+    Set Test Variable              ${sender_id}    0x55
+    Set Test Variable              ${receiver_id}  0xAA
+    Set Test Variable              ${msg_hex}      C001D00D
+
+    Execute Command                mach create "machine"
+    Execute Command                machine LoadPlatformDescriptionFromString "external_client_bus: CAN.CANExternalControlBus @ sysbus"
+    Execute Command                emulation CreateCANHub "canHub"
+    Execute Command                connector Connect external_client_bus canHub
+
+    Create Can Tester              canHub
+
+    Build Sample                   can
+
+    ${proc}=                       Start Sample  can  ${PORT}  machine  external_client_bus
+    
+    Send ISOTP Message             ${sender_id}  ${receiver_id}  ${msg_hex}  
+    ${received_msg}=               Wait For Frame With Id         0x55   timeout=10000
+    ${received_hex}=               Evaluate  $received_msg.hex().upper()
+    Should Contain                 ${received_hex}  ${msg_hex}
+
diff --git a/tools/external_control_client/examples/can/main.c b/tools/external_control_client/examples/can/main.c
new file mode 100644
index 0000000..5cc6877
--- /dev/null
+++ b/tools/external_control_client/examples/can/main.c
@@ -0,0 +1,157 @@
+//
+// Copyright (c) 2010-2026 Antmicro
+//
+// This file is licensed under MIT License.
+// Full license text is available in 'licenses/MIT.txt' file.
+//
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <inttypes.h>
+#include <unistd.h>
+#include <stdlib.h>
+#include <pthread.h>
+
+#include "renode_api.h"
+
+uint8_t next_packet_to_send[MAX_CAN_FRAME_SIZE];
+int next_packet_length;
+uint32_t next_packet_id;
+pthread_mutex_t next_packet_lock;
+
+static void exit_with_usage_info(const char *argv0)
+{
+    fprintf(stderr,
+        "Usage:\n"
+        "  %s <PORT> <MACHINE_NAME> <CAN_NAME>\n",
+        argv0);
+    exit(EXIT_FAILURE);
+}
+
+static char *get_error_message(renode_error_t *error)
+{
+    if (error->message == NULL)
+    {
+        return "<no message>";
+    }
+    return error->message;
+}
+
+static int try_renode_disconnect(renode_t **renode)
+{
+    renode_error_t *error;
+    if ((error = renode_disconnect(renode)) != NO_ERROR) {
+        fprintf(stderr, "Disconnecting from Renode failed with: %s\n", get_error_message(error));
+        return -1;
+    }
+    return 0;
+}
+
+typedef struct {
+    char *machine_name;
+    char *can_name;
+} can_event_user_data;
+
+void can_callback(void *user_data, renode_can_event_data_t *event_data)
+{
+    
+    can_event_user_data *udata = (can_event_user_data *)user_data;
+    printf("%s:%s can packet with length %d received:\n\t", udata->machine_name, udata->can_name, event_data->packet_length);
+
+
+    printf("0x");
+    for (int current_position = 0; current_position < event_data->packet_length; current_position++) {
+        printf("%02x", event_data->packet[current_position]);
+    }
+    printf("\n");
+    fflush(stdout);
+
+    pthread_mutex_lock(&next_packet_lock);
+    memcpy(&next_packet_to_send, &event_data->packet, event_data->packet_length);
+    next_packet_id = event_data->packet_id;
+    next_packet_length = event_data->packet_length;
+    pthread_mutex_unlock(&next_packet_lock);
+}
+
+int main(int argc, char **argv)
+{
+    if (argc < 4) {
+        exit_with_usage_info(argv[0]);
+    }
+    char *port_number_string = argv[1];
+    char *machine_name = argv[2];
+    char *can_name = argv[3];
+
+    pthread_mutex_init(&next_packet_lock, NULL);
+
+    // get Renode, machine and can instances
+    renode_error_t *error;
+    renode_t *renode;
+    if ((error = renode_connect(port_number_string, &renode)) != NO_ERROR) {
+        fprintf(stderr, "Connecting to Renode failed with: %s\n", get_error_message(error));
+        goto fail;
+    }
+
+    renode_machine_t *machine;
+    if ((error = renode_get_machine(renode, machine_name, &machine)) != NO_ERROR) {
+        fprintf(stderr, "Getting '%s' machine object failed with: %s\n", machine_name, get_error_message(error));
+        goto fail_renode;
+    }
+
+    renode_can_t *can;
+    if ((error = renode_get_can(machine, can_name, &can)) != NO_ERROR) {
+        fprintf(stderr, "Getting '%s' CAN object failed with: %s\n", can_name, get_error_message(error));
+        goto fail_machine;
+    }
+
+    can_event_user_data user_data = (can_event_user_data){
+            .machine_name = machine_name,
+            .can_name = can_name,
+            };
+
+    if ((error = renode_register_can_callback(can, &user_data, can_callback)) != NO_ERROR) {
+        fprintf(stderr, "Registering CAN event for peripheral '%s' failed with: '%s'\n", can_name, get_error_message(error));
+        goto fail_can;
+    }
+
+    while(true){
+
+        pthread_mutex_lock(&next_packet_lock);
+        if (next_packet_length > 0)
+        {
+
+            printf("About to send can message");
+            fflush(stdout);
+            if((error = renode_send_can_message(can, &next_packet_to_send, next_packet_length, next_packet_id))) {
+                fprintf(stderr, "send can message failed with: %s\n", get_error_message(error));
+                goto fail_can;
+            }
+            next_packet_length = 0;
+        }
+        pthread_mutex_unlock(&next_packet_lock);
+    }
+    // clean up
+
+    free(can);
+    free(machine);
+    if (try_renode_disconnect(&renode)) {
+        exit(EXIT_FAILURE);
+    }
+
+    fflush(stdout);
+    exit(EXIT_SUCCESS);
+
+fail_can:
+    free(can);
+fail_machine:
+    free(machine);
+fail_renode:
+    try_renode_disconnect(&renode);
+    free(renode);
+fail:
+    renode_free_error(error);
+    fflush(stdout);
+    fflush(stderr);
+    exit(EXIT_FAILURE);
+}
diff --git a/tools/external_control_client/include/renode_api.h b/tools/external_control_client/include/renode_api.h
index f7c9f16..0e5e645 100644
--- a/tools/external_control_client/include/renode_api.h
+++ b/tools/external_control_client/include/renode_api.h
@@ -83,6 +83,13 @@
 typedef struct renode_gpio renode_gpio_t;
 
 /**
+ * @brief Renode CAN bus API handle
+ *
+ * @copydetails renode_t
+ */
+typedef struct renode_can renode_can_t;
+
+/**
  * @brief Renode bus manager API handle
  *
  * @copydetails renode_t
@@ -358,6 +365,54 @@
 renode_error_t *renode_register_gpio_state_change_callback(renode_gpio_t *gpio, int32_t id, void *user_data, void (*callback)(void *, renode_gpio_event_data_t *));
 
 
+/* CAN */
+
+#define MAX_CAN_FRAME_SIZE 64
+
+/**
+ * CAN bus event data
+ */
+typedef struct {
+    renode_time_t time;
+    int32_t packet_length;
+    uint32_t packet_id;
+    uint8_t packet[];
+} renode_can_event_data_t;
+
+/**
+ * @brief Function preparing CAN handle
+ *
+ * @note Handle's internal memory is dynamically allocated so `*can` should be freed when it's no longer used.
+ *
+ * @param[in] machine machine handle
+ * @param[in] name CANBus name
+ * @param[out] can handle associated with the requested CAN instance
+ * @return a pointer to error structure if error occurred, otherwise NULL
+ */
+renode_error_t *renode_get_can(renode_machine_t *machine, const char *name, renode_can_t **can);
+
+/**
+ * @brief Function registering callback when CAN message is received
+ *
+ * @param[in] can CAN instance handle
+ * @param[in] user_data pointer to data passed to the callback when it's invoked
+ * @param[in] callback callback to be invoked when message is received
+ * @return a pointer to error structure if error occurred, otherwise NULL
+ */
+renode_error_t *renode_register_can_callback(renode_can_t *can, void *user_data, void (*callback)(void *, renode_can_event_data_t *));
+
+/**
+ * @brief Function sending CAN message
+ *
+ * @param[in] can CAN instance handle
+ * @param[in] packet pointer to fixed size, 64 byte can packet array
+ * @param[in] packet_length length of send packet
+ * @param[in] packet_id packet_id
+ * @return a pointer to error structure if error occurred, otherwise NULL
+ */
+renode_error_t *renode_send_can_message(renode_can_t *can, void *packet, int packet_length, uint32_t packet_id);
+ 
+
 /* System bus */
 
 /**
diff --git a/tools/external_control_client/lib/common.h b/tools/external_control_client/lib/common.h
index ef4fc35..b4e6dff 100644
--- a/tools/external_control_client/lib/common.h
+++ b/tools/external_control_client/lib/common.h
@@ -40,6 +40,7 @@
     SYSTEM_BUS,
     TIME_ELAPSED_CALLBACK,
     SPI,
+    CAN_BUS,
 } api_command_t;
 
 // Needs to be in sync with `Antmicro.Renode.Network.ExternalControl.CommandType` in C#
diff --git a/tools/external_control_client/lib/renode_api.c b/tools/external_control_client/lib/renode_api.c
index 2800aba..eec654b 100644
--- a/tools/external_control_client/lib/renode_api.c
+++ b/tools/external_control_client/lib/renode_api.c
@@ -4,6 +4,7 @@
 // This file is licensed under MIT License.
 // Full license text is available in 'licenses/MIT.txt' file.
 //
+#include "renode_api.h"
 #include <fcntl.h>
 #include <inttypes.h>
 #include <netdb.h>
@@ -39,6 +40,11 @@
     int32_t id;
 };
 
+struct renode_can {
+    renode_machine_t *machine;
+    int32_t id;
+};
+
 struct renode_bus_context {
     renode_machine_t *machine;
     int32_t id;
@@ -144,7 +150,6 @@
 #define MAX_CALLBACK_COUNT 1024
 
 typedef void (*raw_callback_t)(void *, void *);
-
 static raw_callback_t callbacks[MAX_CALLBACK_COUNT];
 static void *callback_user_data[MAX_CALLBACK_COUNT];
 static int32_t callbacks_count;
@@ -181,6 +186,19 @@
 
         callbacks[ed](callback_user_data[ed], &event_data);
         return renode_connection_send_message(conn, id, RESPONSE_HEADER(cmd, TYPE_SUCCESS));
+    case CAN_BUS:
+        assert_response(size >= sizeof(renode_can_event_data_t), ERRMSG_UNEXPECTED_RESPONSE_PAYLOAD_SIZE);
+        assert_response(size <= sizeof(renode_can_event_data_t) + MAX_CAN_FRAME_SIZE, ERRMSG_UNEXPECTED_RESPONSE_PAYLOAD_SIZE);
+
+        renode_can_event_data_t *can_data;
+        can_data = xmalloc(size);
+        memcpy(can_data, data, size);
+
+        callbacks[ed](callback_user_data[ed], can_data);
+        assert_response(size == sizeof(renode_can_event_data_t) + can_data->packet_length, ERRMSG_UNEXPECTED_RESPONSE_PAYLOAD_SIZE);
+        renode_error_t *result = renode_connection_send_message(conn, id, RESPONSE_HEADER(cmd, TYPE_SUCCESS));
+        free(can_data);
+        return result;
     case TIME_ELAPSED_CALLBACK:
         assert_response(size == sizeof(renode_time_t), ERRMSG_UNEXPECTED_RESPONSE_PAYLOAD_SIZE);
 
@@ -571,6 +589,75 @@
     );
 }
 
+typedef enum 
+{
+    SEND_FRAME = 0,
+    RECEIVED_FRAME = 1,
+    REGISTER_CALLBACKS = 2,
+} canbus_operation_t;
+
+typedef struct {
+    struct __attribute__((packed)) {
+        int32_t id;
+        int8_t command;
+    } header;
+    int32_t packet_length;
+    uint32_t id;
+    void * packet;
+} canbus_command_t;
+
+renode_error_t *renode_get_can(renode_machine_t *machine, const char *name, renode_can_t **can)
+{
+    int32_t id;
+    return_error_if_fails(renode_get_instance_descriptor(machine, CAN_BUS, name, &id));
+
+    *can = xmalloc(sizeof(renode_can_t));
+    (*can)->machine = machine;
+    (*can)->id = id;
+
+    return NO_ERROR;
+}
+
+renode_error_t *renode_send_can_message(renode_can_t *can, void *packet, int packet_length, uint32_t packet_id)
+{
+    canbus_command_t cmd = {
+        .header = {
+         .id = can->id,
+         .command = SEND_FRAME,
+         },
+        .packet_length = packet_length,
+        .id = packet_id,
+    };
+
+    return_error_if_fails(renode_connection_send_request(can->machine->renode->conn, generic_response_handler, &cmd,
+        REQUEST_HEADER(CAN_BUS), 
+        {&cmd.header, sizeof(cmd.header)},
+        {&cmd.packet_length, sizeof(cmd.packet_length)},
+        {&cmd.id, sizeof(cmd.id)},
+        {packet, packet_length},
+        ));
+
+    return NO_ERROR;
+}
+
+renode_error_t *renode_register_can_callback(renode_can_t *can, void *user_data, void (*callback)(void *, renode_can_event_data_t *))
+{
+    int32_t ed;
+    return_error_if_fails(register_callback((raw_callback_t)callback, user_data, &ed));
+
+    canbus_command_t cmd = {
+        .header = {
+            .command = REGISTER_CALLBACKS,
+        },
+    };
+
+    return renode_connection_send_request(can->machine->renode->conn, generic_response_handler, &cmd,
+        REQUEST_HEADER(CAN_BUS),
+        {&cmd.header, sizeof(cmd.header)},
+        {&ed, sizeof(ed)},
+    );
+}
+
 renode_error_t *renode_get_bus_context(renode_machine_t *machine, const char *name, renode_bus_context_t **ctx)
 {
     int32_t id;
@@ -814,6 +901,8 @@
         return NO_ERROR; // This command doesn't return any data, we only care about a successful status
     case SPI:
         return NO_ERROR; // This command doesn't return any data, we only care about a successful status
+    case CAN_BUS:
+        return NO_ERROR; // This command doesn't return any data, we only care about a successful status
     default:
         return create_fatal_error_static("Encountered a command without a response handler");
     }