expand harness for master/slave, add i2c test
diff --git a/target/ast10x0/defs.bzl b/target/ast10x0/defs.bzl
index 7b70d6f..dd10172 100644
--- a/target/ast10x0/defs.bzl
+++ b/target/ast10x0/defs.bzl
@@ -2,7 +2,50 @@
 # SPDX-License-Identifier: Apache-2.0
 """Common definitions used by all ast10x0 targets."""
 
+load("@pigweed//pw_kernel/tooling:system_image.bzl", "SystemImageInfo")
+
 TARGET_COMPATIBLE_WITH = select({
     "//target/ast10x0:target_ast10x0": [],
     "//conditions:default": ["@platforms//:incompatible"],
 })
+
+def _system_image_test_impl(ctx):
+    master_elf = ctx.attr.image[SystemImageInfo].elf
+    executable_symlink = ctx.actions.declare_file(ctx.label.name)
+    ctx.actions.symlink(output = executable_symlink, target_file = master_elf)
+
+    runfiles = ctx.attr.image[DefaultInfo].default_runfiles
+
+    if ctx.attr.slave_image:
+        slave_elf = ctx.attr.slave_image[SystemImageInfo].elf
+        slave_symlink = ctx.actions.declare_file(ctx.label.name + ".slave.elf")
+        ctx.actions.symlink(output = slave_symlink, target_file = slave_elf)
+        runfiles = ctx.runfiles(files = [slave_symlink]).merge(
+            runfiles.merge(ctx.attr.slave_image[DefaultInfo].default_runfiles),
+        )
+
+    return [DefaultInfo(
+        executable = executable_symlink,
+        runfiles = runfiles,
+    )]
+
+system_image_test = rule(
+    implementation = _system_image_test_impl,
+    test = True,
+    attrs = {
+        "image": attr.label(
+            doc = "The system_image target to test.",
+            mandatory = True,
+            providers = [SystemImageInfo],
+            executable = True,
+            cfg = "target",
+        ),
+        "slave_image": attr.label(
+            doc = "Optional slave system_image for paired two-device tests.",
+            mandatory = False,
+            default = None,
+            providers = [SystemImageInfo],
+            cfg = "target",
+        ),
+    },
+)
diff --git a/target/ast10x0/harness/evb_config.toml b/target/ast10x0/harness/evb_config.toml
index 249e6d9..c169c60 100644
--- a/target/ast10x0/harness/evb_config.toml
+++ b/target/ast10x0/harness/evb_config.toml
@@ -8,3 +8,8 @@
 [uart]
 serial_port = "/dev/ttyUSB0"
 baudrate = 115200
+
+[device_b]
+srst_pin = 25
+fwspick_pin = 24
+serial_port = "/dev/ttyUSB1"
diff --git a/target/ast10x0/harness/pi_test_runner.py b/target/ast10x0/harness/pi_test_runner.py
index 51d8d22..bd655cf 100644
--- a/target/ast10x0/harness/pi_test_runner.py
+++ b/target/ast10x0/harness/pi_test_runner.py
@@ -13,7 +13,9 @@
 import argparse
 import subprocess
 import sys
+import threading
 import time
+from contextlib import nullcontext
 from pathlib import Path
 
 try:
@@ -75,11 +77,13 @@
     print(f"Uploaded {size} bytes ({padding} bytes padding)", file=sys.stderr)
 
 
+_stdout_lock = threading.Lock()
+
 _SUCCESS_SENTINEL = b"TEST_RESULT:PASS"
 _FAILURE_SENTINELS = [b"TEST_RESULT:FAIL", b"panic"]
 
 
-def _stream_uart(port: serial.Serial, timeout: int) -> bool:
+def _stream_uart(port: serial.Serial, timeout: int, lock=None) -> bool:
     port.timeout = 1.0
     deadline = time.time() + timeout if timeout else None
     buf = b""
@@ -90,8 +94,9 @@
         data = port.read(1024)
         if data:
             try:
-                sys.stdout.buffer.write(data)
-                sys.stdout.buffer.flush()
+                with (lock or nullcontext()):
+                    sys.stdout.buffer.write(data)
+                    sys.stdout.buffer.flush()
             except (BrokenPipeError, OSError):
                 return False
             buf += data
@@ -103,6 +108,63 @@
             buf = buf[-256:]
 
 
+def _run_paired(args, firmware_path: Path, slave_firmware_path: Path) -> bool:
+    try:
+        port_b = serial.Serial(
+            args.slave_uart_device,
+            baudrate=args.baudrate,
+            timeout=1.0,
+            write_timeout=1.0,
+        )
+    except serial.SerialException as e:
+        print(f"Error: could not open {args.slave_uart_device}: {e}", file=sys.stderr)
+        return False
+
+    try:
+        port_a = serial.Serial(
+            args.uart_device,
+            baudrate=args.baudrate,
+            timeout=1.0,
+            write_timeout=1.0,
+        )
+    except serial.SerialException as e:
+        port_b.close()
+        print(f"Error: could not open {args.uart_device}: {e}", file=sys.stderr)
+        return False
+
+    try:
+        _sequence_to_fwspick_mode(args.slave_srst_pin, args.slave_fwspick_pin, port_b)
+        if not _wait_for_uart_ready(port_b):
+            return False
+        _upload_firmware(port_b, slave_firmware_path)
+
+        _sequence_to_fwspick_mode(args.srst_pin, args.fwspick_pin, port_a)
+        if not _wait_for_uart_ready(port_a):
+            return False
+        _upload_firmware(port_a, firmware_path)
+
+        results = [None, None]
+
+        def _monitor(idx, port):
+            results[idx] = _stream_uart(port, args.timeout, _stdout_lock)
+
+        threads = [
+            threading.Thread(target=_monitor, args=(0, port_a)),
+            threading.Thread(target=_monitor, args=(1, port_b)),
+        ]
+        for t in threads:
+            t.start()
+        for t in threads:
+            t.join()
+
+        return bool(results[0] and results[1])
+    except KeyboardInterrupt:
+        return False
+    finally:
+        port_a.close()
+        port_b.close()
+
+
 def main() -> int:
     parser = argparse.ArgumentParser(
         description="AST1060 EVB hardware layer: GPIO, firmware upload, UART stream"
@@ -145,6 +207,28 @@
         action="store_true",
         help="Skip GPIO sequences and firmware upload; stream raw UART bytes only",
     )
+    parser.add_argument(
+        "--slave-firmware",
+        default=None,
+        help="Slave firmware binary. When present, enables paired two-device mode.",
+    )
+    parser.add_argument(
+        "--slave-uart-device",
+        default=None,
+        help="Serial port for device B (e.g. /dev/ttyUSB1)",
+    )
+    parser.add_argument(
+        "--slave-srst-pin",
+        type=int,
+        default=None,
+        help="BCM GPIO pin connected to device B SRST",
+    )
+    parser.add_argument(
+        "--slave-fwspick-pin",
+        type=int,
+        default=None,
+        help="BCM GPIO pin connected to device B FWSPICK",
+    )
     args = parser.parse_args()
 
     if not args.stream_only:
@@ -157,6 +241,24 @@
     else:
         firmware_path = None
 
+    if args.slave_firmware:
+        missing = [
+            name
+            for name, val in [
+                ("--slave-uart-device", args.slave_uart_device),
+                ("--slave-srst-pin", args.slave_srst_pin),
+                ("--slave-fwspick-pin", args.slave_fwspick_pin),
+            ]
+            if val is None
+        ]
+        if missing:
+            parser.error(f"paired mode requires: {', '.join(missing)}")
+        slave_firmware_path = Path(args.slave_firmware)
+        if not slave_firmware_path.exists():
+            print(f"Error: slave firmware not found: {slave_firmware_path}", file=sys.stderr)
+            return 1
+        return 0 if _run_paired(args, firmware_path, slave_firmware_path) else 1
+
     try:
         port = serial.Serial(
             args.uart_device,
diff --git a/target/ast10x0/harness/test_runner.py b/target/ast10x0/harness/test_runner.py
index 55c0e2d..e04b432 100644
--- a/target/ast10x0/harness/test_runner.py
+++ b/target/ast10x0/harness/test_runner.py
@@ -127,13 +127,12 @@
 class UartMonitor:
     """Detokenizes and displays raw UART bytes. Pass/fail is determined by pi_test_runner.py exit code."""
 
-    def __init__(self, args: argparse.Namespace, elf_path: Path) -> None:
-        # elf_path is always derived on the host from firmware path; caller
-        # validates existence before constructing this object.
+    def __init__(self, args: argparse.Namespace, *elf_paths: Path) -> None:
         self.args = args
         self.log_file_handle = open(args.log_file, "w") if args.log_file else None
+        elf_args = [str(p) for p in elf_paths if p is not None]
         self.detokenizer = (
-            Detokenizer(str(elf_path)) if _PW_TOKENIZER_AVAILABLE else None
+            Detokenizer(*elf_args) if _PW_TOKENIZER_AVAILABLE and elf_args else None
         )
         self._token_parser = NestedMessageParser() if _PW_TOKENIZER_AVAILABLE else None
 
@@ -257,6 +256,15 @@
     else:
         remote_fw = None
 
+    remote_slave_fw = None
+    if args.slave_firmware:
+        slave_firmware_path = Path(args.slave_firmware)
+        remote_slave_fw = f"{remote_dir}/{slave_firmware_path.name}"
+        subprocess.run(
+            ["scp", "-q", str(slave_firmware_path), f"{host}:{remote_slave_fw}"],
+            check=True,
+        )
+
     remote_cmd = f"python3 -u {remote_dir}/pi_test_runner.py {uart_device}"
     if not args.parse_only:
         remote_cmd += f" {remote_fw}"
@@ -268,6 +276,14 @@
     )
     if args.parse_only:
         remote_cmd += " --stream-only"
+    if remote_slave_fw:
+        device_b = config["device_b"]
+        remote_cmd += (
+            f" --slave-firmware {remote_slave_fw}"
+            f" --slave-uart-device {device_b['serial_port']}"
+            f" --slave-srst-pin {device_b['srst_pin']}"
+            f" --slave-fwspick-pin {device_b['fwspick_pin']}"
+        )
 
     proc = _ssh_stream(host, remote_cmd)
     try:
@@ -348,8 +364,21 @@
     # When invoked via --run_under on a system_image_test, Bazel passes the
     # no-suffix symlink (e.g. threads_test → threads.elf); resolve it first.
     image = Path(args.firmware)
+
+    # When invoked via --run_under the firmware path has no suffix (it is the
+    # test-name symlink).  The system_image_test rule places a companion
+    # <name>.slave.elf symlink alongside it when slave_image is set; detecting
+    # that file is how we enter paired mode without any extra CLI arguments.
+    args.slave_firmware = None
+    slave_elf_path = None
     if not image.suffix:
+        slave_symlink = image.parent / (image.name + ".slave.elf")
+        if slave_symlink.exists():
+            slave_elf = slave_symlink.resolve()
+            slave_elf_path = slave_elf
+            args.slave_firmware = str(slave_elf.with_suffix(".bin"))
         image = image.resolve()
+
     elf_path = image.with_suffix(".elf")
     args.firmware = str(image.with_suffix(".bin"))
     if not elf_path.exists():
@@ -359,7 +388,7 @@
     args.pi_host = os.environ.get(AST1060_EVB_PI_HOST) or args.pi_host
 
     runner = Path(__file__).parent / "pi_test_runner.py"
-    monitor = UartMonitor(args, elf_path)
+    monitor = UartMonitor(args, elf_path, slave_elf_path)
 
     signal.signal(signal.SIGINT, lambda s, f: sys.exit(130))
 
diff --git a/target/ast10x0/peripherals/i2c/slave.rs b/target/ast10x0/peripherals/i2c/slave.rs
index 729c825..6391910 100644
--- a/target/ast10x0/peripherals/i2c/slave.rs
+++ b/target/ast10x0/peripherals/i2c/slave.rs
@@ -127,11 +127,14 @@
         if self.xfer_mode == I2cXferMode::DmaMode {
             self.regs().i2cs4c().read().dmarx_actual_len_byte().bits() as usize
         } else {
+            // Hardware includes the I2C address byte in the buffer count (packet mode,
+            // I2CC00 bit 20). Subtract 1 to report only the payload byte count.
             self.regs()
                 .i2cc0c()
                 .read()
                 .actual_rxd_pool_buffer_size()
-                .bits() as usize
+                .bits()
+                .saturating_sub(1) as usize
         }
     }
 
@@ -561,12 +564,17 @@
                 });
             } else if sts == constants::AST_I2CS_TX_NAK | constants::AST_I2CS_STOP
                 || sts == constants::AST_I2CS_STOP
+                || sts
+                    == constants::AST_I2CS_SLAVE_MATCH
+                        | constants::AST_I2CS_TX_NAK
+                        | constants::AST_I2CS_STOP
             {
-                // S: (TX_NAK)|P
+                // S: (Sr) (TX_NAK)|P — master read completed with NAK then STOP
                 self.arm_slave_receive(&mut cmd);
                 unsafe {
                     self.regs().i2cs28().write(|w| w.bits(cmd));
                 }
+                return Some(SlaveEvent::Stop);
             } else {
                 // TODO packet slave sts
             }
diff --git a/target/ast10x0/peripherals/scu/pinctrl.rs b/target/ast10x0/peripherals/scu/pinctrl.rs
index bd92529..3f4cccd 100644
--- a/target/ast10x0/peripherals/scu/pinctrl.rs
+++ b/target/ast10x0/peripherals/scu/pinctrl.rs
@@ -537,9 +537,21 @@
     gen_pin_pairs!(SCU6B0, 0x6B0, 31);
 }
 
-/// I2C1 pin group: SCL/SDA mux selection on SCU414[30:31].
+/// I2C1 pin group: SCL2/SDA2 mux selection on SCU414[30:31].
+///
+/// The SVD names these EnblSCL2FnPin/EnblSDA2FnPin, but they correspond to
+/// PAC peripheral I2c1 (controller 1, base 0x7e7b_0100). The SVD uses
+/// 1-based naming where "2" means "second bus", matching PAC I2c1.
 pub const PINCTRL_I2C1: &[PinctrlPin] = &[PIN_SCU414_30, PIN_SCU414_31];
 
+/// I2C2 pin group: SCL3/SDA3 mux selection on SCU418[0:1].
+///
+/// The SVD names these EnblSCL3FnPin/EnblSDA3FnPin, corresponding to
+/// PAC peripheral I2c2 (controller 2, base 0x7e7b_0180). On the Test
+/// Harness board, these pins are exposed on J15 and used for inter-device
+/// I2C communication between the two AST1060 daughter cards.
+pub const PINCTRL_I2C2: &[PinctrlPin] = &[PIN_SCU418_0, PIN_SCU418_1];
+
 /// Macro to safely modify a register bit (set or clear).
 macro_rules! modify_reg {
     ($reg:expr, $bit:expr, $clear:expr) => {{
diff --git a/target/ast10x0/tests/peripherals/i2c/i2c_irq/BUILD.bazel b/target/ast10x0/tests/peripherals/i2c/i2c_irq/BUILD.bazel
new file mode 100644
index 0000000..21b2dbf
--- /dev/null
+++ b/target/ast10x0/tests/peripherals/i2c/i2c_irq/BUILD.bazel
@@ -0,0 +1,132 @@
+# Licensed under the Apache-2.0 license
+# SPDX-License-Identifier: Apache-2.0
+
+load("@pigweed//pw_kernel/tooling:system_image.bzl", "system_image")
+load("@pigweed//pw_kernel/tooling:target_codegen.bzl", "target_codegen")
+load("@pigweed//pw_kernel/tooling:target_linker_script.bzl", "target_linker_script")
+load("@pigweed//pw_kernel/tooling/panic_detector:rust_binary_no_panics_test.bzl", "rust_binary_no_panics_test")
+load("@rules_rust//rust:defs.bzl", "rust_binary")
+load("//target/ast10x0:defs.bzl", "TARGET_COMPATIBLE_WITH", "system_image_test")
+
+COMMON_DEPS = [
+    "//target/ast10x0:config",
+    "//target/ast10x0:entry",
+    "//target/ast10x0/board:ast10x0_board",
+    "//target/ast10x0/peripherals",
+    "@ast1060_pac",
+    "@pigweed//pw_kernel/arch/arm_cortex_m:arch_arm_cortex_m",
+    "@pigweed//pw_kernel/kernel",
+    "@pigweed//pw_kernel/subsys/console:console_backend",
+    "@pigweed//pw_kernel/target:target_common",
+    "@pigweed//pw_log/rust:pw_log",
+    "@pigweed//pw_status/rust:pw_status",
+    "@rust_crates//:cortex-m-semihosting",
+]
+
+# ---------------------------------------------------------------------------
+# Master image (device A)
+# ---------------------------------------------------------------------------
+
+filegroup(
+    name = "system_config",
+    srcs = ["system.json5"],
+)
+
+target_codegen(
+    name = "codegen",
+    arch = "@pigweed//pw_kernel/arch/arm_cortex_m:arch_arm_cortex_m",
+    system_config = ":system_config",
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+)
+
+target_linker_script(
+    name = "linker_script",
+    system_config = ":system_config",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+    template = "//target/ast10x0:linker_script_template",
+)
+
+rust_binary(
+    name = "target",
+    srcs = ["target.rs"],
+    edition = "2024",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+    deps = [":codegen", ":linker_script"] + COMMON_DEPS,
+)
+
+system_image(
+    name = "master",
+    kernel = ":target",
+    platform = "//target/ast10x0",
+    system_config = ":system_config",
+    tags = ["kernel"],
+    userspace = False,
+)
+
+system_image_test(
+    name = "irq_test",
+    image = ":master",
+    slave_image = ":slave",
+    tags = ["hardware"],
+    target_compatible_with = select({
+        "//target/ast10x0:qemu_enabled": ["@platforms//:incompatible"],
+        "//conditions:default": [],
+    }),
+)
+
+rust_binary_no_panics_test(
+    name = "no_panics_test",
+    binary = ":master",
+    tags = ["kernel"],
+)
+
+# ---------------------------------------------------------------------------
+# Slave image (device B)
+# ---------------------------------------------------------------------------
+
+filegroup(
+    name = "slave_system_config",
+    srcs = ["slave_system.json5"],
+)
+
+target_codegen(
+    name = "slave_codegen",
+    arch = "@pigweed//pw_kernel/arch/arm_cortex_m:arch_arm_cortex_m",
+    system_config = ":slave_system_config",
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+)
+
+target_linker_script(
+    name = "slave_linker_script",
+    system_config = ":slave_system_config",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+    template = "//target/ast10x0:linker_script_template",
+)
+
+rust_binary(
+    name = "slave_target",
+    srcs = ["slave_target.rs"],
+    aliases = {":slave_codegen": "codegen"},
+    edition = "2024",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+    deps = [":slave_codegen", ":slave_linker_script"] + COMMON_DEPS,
+)
+
+system_image(
+    name = "slave",
+    kernel = ":slave_target",
+    platform = "//target/ast10x0",
+    system_config = ":slave_system_config",
+    tags = ["kernel"],
+    userspace = False,
+)
+
+rust_binary_no_panics_test(
+    name = "slave_no_panics_test",
+    binary = ":slave",
+    tags = ["kernel"],
+)
diff --git a/target/ast10x0/tests/peripherals/i2c/i2c_irq/slave_system.json5 b/target/ast10x0/tests/peripherals/i2c/i2c_irq/slave_system.json5
new file mode 100644
index 0000000..3a30e0f
--- /dev/null
+++ b/target/ast10x0/tests/peripherals/i2c/i2c_irq/slave_system.json5
@@ -0,0 +1,17 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+// I2C Slave IRQ Test — slave image (device B). Same layout as master.
+{
+    arch: {
+        type: "armv7m",
+        vector_table_start_address: 0x00000000,
+        vector_table_size_bytes: 1280,
+    },
+    kernel: {
+        flash_start_address: 0x00000500,
+        flash_size_bytes: 262144,
+        ram_start_address: 0x00040500,
+        ram_size_bytes: 393216,
+    },
+}
diff --git a/target/ast10x0/tests/peripherals/i2c/i2c_irq/slave_target.rs b/target/ast10x0/tests/peripherals/i2c/i2c_irq/slave_target.rs
new file mode 100644
index 0000000..085f1af
--- /dev/null
+++ b/target/ast10x0/tests/peripherals/i2c/i2c_irq/slave_target.rs
@@ -0,0 +1,159 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! I2C slave interrupt test — slave side (device B)
+//!
+//! Runs on the AST1060 Test Harness board with J15 pins 1 and 2 connected.
+//! This binary is the SLAVE. Load it on device B before loading the master
+//! image on device A.
+//!
+//! The slave listens at address 0x42, handles three transactions initiated
+//! by the master, verifies the expected interrupt events, then reports PASS.
+
+#![no_std]
+#![no_main]
+
+use ast10x0_board::{Ast10x0Board, Ast10x0BoardDescriptor};
+use ast10x0_peripherals::i2c::{
+    Ast1060I2c, ClockConfig, I2cConfig, I2cSpeed, I2cXferMode, SlaveConfig, SlaveEvent,
+};
+use ast10x0_peripherals::scu::pinctrl;
+use codegen as _;
+use console_backend::console_backend_write_all;
+use entry as _;
+use target_common::{TargetInterface, declare_target};
+
+pub struct Target {}
+
+const SLAVE_ADDR: u8 = 0x42;
+const EXPECTED_WRITE: &[u8] = &[0xAA, 0xBB, 0xCC, 0xDD];
+const READ_RESPONSE: &[u8] = &[0x55];
+
+fn i2c2_config() -> I2cConfig {
+    I2cConfig {
+        xfer_mode: I2cXferMode::BufferMode,
+        speed: I2cSpeed::Fast,
+        multi_master: false,
+        smbus_timeout: true,
+        smbus_alert: false,
+        clock_config: ClockConfig::ast1060_default(),
+    }
+}
+
+/// Poll handle_slave_interrupt until an event arrives or the budget runs out.
+fn wait_event<Y: FnMut(u32)>(
+    slave: &mut Ast1060I2c<'_, Y>,
+    max_polls: u32,
+) -> Option<SlaveEvent> {
+    for _ in 0..max_polls {
+        if let Some(ev) = slave.handle_slave_interrupt() {
+            return Some(ev);
+        }
+        core::hint::spin_loop();
+    }
+    None
+}
+
+fn run_slave() -> Result<(), &'static str> {
+    pw_log::info!("=== I2C slave IRQ test: SLAVE (device B) ===");
+    pw_log::info!("Listening at addr 0x{:02x}. Start master (device A) now.", SLAVE_ADDR as u32);
+
+    let board = Ast10x0Board::new(Ast10x0BoardDescriptor {
+        pinctrl_groups: &[pinctrl::PINCTRL_I2C2],
+    });
+    // SAFETY: single call at boot with exclusive access to SCU/I2C global regs.
+    unsafe { board.init() };
+
+    // SAFETY: I2C2 registers accessed only through `slave` for this test.
+    let mut slave = unsafe {
+        Ast1060I2c::new(
+            ast1060_pac::I2c2::ptr(),
+            ast1060_pac::I2cbuff2::ptr(),
+            &i2c2_config(),
+            |_| core::hint::spin_loop(),
+        )
+    }
+    .map_err(|_| "slave I2C2 init failed")?;
+
+    let slave_cfg = SlaveConfig::new(SLAVE_ADDR).map_err(|_| "SlaveConfig::new failed")?;
+
+    // ------------------------------------------------------------------
+    // Test 1: receive master write → expect DataReceived
+    // ------------------------------------------------------------------
+    slave
+        .configure_slave(&slave_cfg)
+        .map_err(|_| "test 1: configure_slave failed")?;
+
+    match wait_event(&mut slave, 50_000_000) {
+        Some(SlaveEvent::DataReceived { len }) => {
+            if len != EXPECTED_WRITE.len() {
+                pw_log::error!("test 1: DataReceived len={} expected={}", len as u32, EXPECTED_WRITE.len() as u32);
+                return Err("test 1: DataReceived len mismatch");
+            }
+            pw_log::info!("Test 1 passed: DataReceived len={}", len as u32);
+        }
+        Some(_) => return Err("test 1: unexpected slave event"),
+        None => return Err("test 1: timed out waiting for DataReceived"),
+    }
+
+    // ------------------------------------------------------------------
+    // Test 2: respond to master read → pre-arm TX, expect DataSent
+    // ------------------------------------------------------------------
+    slave
+        .configure_slave(&slave_cfg)
+        .map_err(|_| "test 2: configure_slave failed")?;
+
+    slave
+        .slave_write(READ_RESPONSE)
+        .map_err(|_| "test 2: slave_write failed")?;
+
+    match wait_event(&mut slave, 50_000_000) {
+        Some(SlaveEvent::DataSent { len }) => {
+            pw_log::info!("Test 2 passed: DataSent len={}", len as u32);
+        }
+        Some(SlaveEvent::Stop) => {
+            pw_log::info!("Test 2 passed: Stop (short read path)");
+        }
+        Some(_) => return Err("test 2: unexpected slave event"),
+        None => return Err("test 2: timed out waiting for DataSent"),
+    }
+
+    // ------------------------------------------------------------------
+    // Test 3: single-byte write from master → DataReceived then Stop
+    // ------------------------------------------------------------------
+    slave
+        .configure_slave(&slave_cfg)
+        .map_err(|_| "test 3: configure_slave failed")?;
+
+    match wait_event(&mut slave, 50_000_000) {
+        Some(SlaveEvent::DataReceived { len: _ })
+        | Some(SlaveEvent::Stop)
+        | Some(SlaveEvent::WriteRequest) => {
+            pw_log::info!("Test 3 passed: data/stop/write-req observed");
+        }
+        Some(_) => return Err("test 3: unexpected slave event"),
+        None => return Err("test 3: timed out waiting for event"),
+    }
+
+    pw_log::info!("=== Slave tests complete ===");
+    Ok(())
+}
+
+impl TargetInterface for Target {
+    const NAME: &'static str = "AST10x0 I2C Slave IRQ Slave";
+
+    fn main() -> ! {
+        let sentinel: &[u8] = match run_slave() {
+            Ok(()) => b"TEST_RESULT:PASS\n",
+            Err(e) => {
+                pw_log::error!("Slave test failed: {}", e as &str);
+                b"TEST_RESULT:FAIL\n"
+            }
+        };
+        let _ = console_backend_write_all(sentinel);
+        #[expect(clippy::empty_loop)]
+        loop {}
+    }
+}
+
+declare_target!(Target);
diff --git a/target/ast10x0/tests/peripherals/i2c/i2c_irq/system.json5 b/target/ast10x0/tests/peripherals/i2c/i2c_irq/system.json5
new file mode 100644
index 0000000..c11b564
--- /dev/null
+++ b/target/ast10x0/tests/peripherals/i2c/i2c_irq/system.json5
@@ -0,0 +1,19 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+// AST10x0 I2C Slave IRQ Test Configuration
+// Single kernel binary; no userspace apps.
+// Memory layout mirrors i2c_init to keep the linker happy.
+{
+    arch: {
+        type: "armv7m",
+        vector_table_start_address: 0x00000000,
+        vector_table_size_bytes: 1280,  // 0x500 (320 vectors)
+    },
+    kernel: {
+        flash_start_address: 0x00000500,  // After vector table
+        flash_size_bytes: 262144,         // 256KB for kernel code (in RAM)
+        ram_start_address: 0x00040500,    // RAM starts after code
+        ram_size_bytes: 393216,           // 384KB for data
+    },
+}
diff --git a/target/ast10x0/tests/peripherals/i2c/i2c_irq/target.rs b/target/ast10x0/tests/peripherals/i2c/i2c_irq/target.rs
new file mode 100644
index 0000000..3dc8758
--- /dev/null
+++ b/target/ast10x0/tests/peripherals/i2c/i2c_irq/target.rs
@@ -0,0 +1,121 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! I2C slave interrupt test — master side (device A)
+//!
+//! Runs on the AST1060 Test Harness board with J15 pins 1 and 2 connected,
+//! which links I2C2 (PAC I2c2, SCU418[0:1]) between device A and device B.
+//!
+//! This binary is the MASTER. Load the companion slave binary
+//! (i2c_slave_irq_slave image) on device B first, then load this image on
+//! device A.  Device B must be running and listening before device A starts
+//! transmitting.
+//!
+//! Tests exercised:
+//!   1. Master write → slave DataReceived interrupt
+//!   2. Master read  → slave DataSent interrupt (slave pre-arms TX)
+//!   3. Zero-length write → slave Stop event
+
+#![no_std]
+#![no_main]
+
+use ast10x0_board::{Ast10x0Board, Ast10x0BoardDescriptor};
+use ast10x0_peripherals::i2c::{Ast1060I2c, ClockConfig, I2cConfig, I2cSpeed, I2cXferMode};
+use ast10x0_peripherals::scu::pinctrl;
+use codegen as _;
+use console_backend::console_backend_write_all;
+use entry as _;
+use target_common::{TargetInterface, declare_target};
+
+pub struct Target {}
+
+const SLAVE_ADDR: u8 = 0x42;
+const WRITE_PAYLOAD: &[u8] = &[0xAA, 0xBB, 0xCC, 0xDD];
+
+fn i2c2_config() -> I2cConfig {
+    I2cConfig {
+        xfer_mode: I2cXferMode::BufferMode,
+        speed: I2cSpeed::Fast,
+        multi_master: false,
+        smbus_timeout: true,
+        smbus_alert: false,
+        clock_config: ClockConfig::ast1060_default(),
+    }
+}
+
+fn run_master() -> Result<(), &'static str> {
+    pw_log::info!("=== I2C slave IRQ test: MASTER (device A) ===");
+    pw_log::info!("J15 must be connected. Load slave image on device B first.");
+
+    let board = Ast10x0Board::new(Ast10x0BoardDescriptor {
+        pinctrl_groups: &[pinctrl::PINCTRL_I2C2],
+    });
+    // SAFETY: single call at boot with exclusive access to SCU/I2C global regs.
+    unsafe { board.init() };
+
+    // SAFETY: I2C2 registers accessed only through `master` for this test.
+    let mut master = unsafe {
+        Ast1060I2c::new(
+            ast1060_pac::I2c2::ptr(),
+            ast1060_pac::I2cbuff2::ptr(),
+            &i2c2_config(),
+            |_| core::hint::spin_loop(),
+        )
+    }
+    .map_err(|_| "master I2C2 init failed")?;
+
+    // ------------------------------------------------------------------
+    // Test 1: master write → slave DataReceived
+    // ------------------------------------------------------------------
+    pw_log::info!("Test 1: master write");
+    master
+        .write(SLAVE_ADDR, WRITE_PAYLOAD)
+        .map_err(|_| "test 1: master write failed (slave not responding — check J15 and slave firmware)")?;
+    pw_log::info!("Test 1 passed");
+
+    // ------------------------------------------------------------------
+    // Test 2: master read → slave DataSent
+    // The slave pre-arms its TX buffer before this read arrives.
+    // ------------------------------------------------------------------
+    pw_log::info!("Test 2: master read");
+    let mut rx = [0u8; 1];
+    master
+        .read(SLAVE_ADDR, &mut rx)
+        .map_err(|_| "test 2: master read failed")?;
+    if rx[0] != 0x55 {
+        pw_log::error!("test 2: got 0x{:02x}, expected 0x55", rx[0] as u32);
+        return Err("test 2: rx data mismatch");
+    }
+    pw_log::info!("Test 2 passed: rx=0x{:02x}", rx[0] as u32);
+
+    // ------------------------------------------------------------------
+    // Test 3: single-byte write → slave Stop event after packet done
+    // ------------------------------------------------------------------
+    pw_log::info!("Test 3: single-byte write (triggers slave Stop)");
+    master
+        .write(SLAVE_ADDR, &[0xFF])
+        .map_err(|_| "test 3: write failed")?;
+    pw_log::info!("Test 3 passed");
+
+    pw_log::info!("=== Master tests complete ===");
+    Ok(())
+}
+
+impl TargetInterface for Target {
+    const NAME: &'static str = "AST10x0 I2C Slave IRQ Master";
+
+    fn main() -> ! {
+        let sentinel: &[u8] = match run_master() {
+            Ok(()) => b"TEST_RESULT:PASS\n",
+            Err(e) => {
+                pw_log::error!("Master test failed: {}", e as &str);
+                b"TEST_RESULT:FAIL\n"
+            }
+        };
+        let _ = console_backend_write_all(sentinel);
+        #[expect(clippy::empty_loop)]
+        loop {}
+    }
+}
+
+declare_target!(Target);