Đọc từ và ghi vào cổng nối tiếp

Web Serial API cho phép các trang web giao tiếp với thiết bị nối tiếp.

François Beaufort
François Beaufort

Web Serial API là gì?

Cổng nối tiếp là giao diện giao tiếp hai chiều cho phép gửi và nhận dữ liệu theo từng byte.

Web Serial API cung cấp một cách để các trang web đọc dữ liệu từ và ghi vào thiết bị nối tiếp bằng JavaScript. Thiết bị nối tiếp được kết nối qua một cổng nối tiếp trên hệ thống của người dùng hoặc thông qua các thiết bị USB và Bluetooth có thể tháo rời mô phỏng cổng nối tiếp.

Nói cách khác, Web Serial API kết nối web và thế giới thực bằng cách cho phép các trang web giao tiếp với thiết bị nối tiếp, chẳng hạn như bộ vi điều khiển và máy in 3D.

API này cũng là bạn đồng hành tuyệt vời với WebUSB vì các hệ điều hành yêu cầu ứng dụng giao tiếp với một số cổng nối tiếp bằng API nối tiếp cấp cao thay vì API USB cấp thấp.

Trường hợp sử dụng được đề xuất

Trong các lĩnh vực giáo dục, theo sở thích và công nghiệp, người dùng kết nối các thiết bị ngoại vi với máy tính của họ. Những thiết bị này thường do bộ vi điều khiển điều khiển thông qua kết nối nối tiếp mà phần mềm tuỳ chỉnh sử dụng. Một số phần mềm tuỳ chỉnh để điều khiển những thiết bị này được xây dựng bằng công nghệ web:

Trong một số trường hợp, các trang web giao tiếp với thiết bị thông qua một ứng dụng tác nhân mà người dùng đã cài đặt theo cách thủ công. Nói cách khác, ứng dụng được phân phối trong một ứng dụng đóng gói thông qua một khung như electron. Và trong các trường hợp khác, người dùng bắt buộc phải thực hiện một bước bổ sung, chẳng hạn như sao chép ứng dụng đã biên dịch vào thiết bị qua ổ đĩa USB flash.

Trong tất cả những trường hợp này, trải nghiệm người dùng sẽ được cải thiện bằng cách cung cấp hoạt động giao tiếp trực tiếp giữa trang web và thiết bị mà trang web đang kiểm soát.

Trạng thái hiện tại

Bước Trạng thái
1. Tạo thông báo giải thích Hoàn tất
2. Tạo bản nháp ban đầu của quy cách Hoàn tất
3. Thu thập ý kiến phản hồi và cải tiến thiết kế Hoàn tất
4. Bản dùng thử theo nguyên gốc Hoàn tất
5. Ra mắt Hoàn tất

Sử dụng API Web Serial

Phát hiện tính năng

Để kiểm tra xem API Web Serial có được hỗ trợ hay không, hãy dùng:

if ("serial" in navigator) {
  // The Web Serial API is supported.
}

Mở cổng nối tiếp

Web Serial API không đồng bộ theo thiết kế. Điều này ngăn giao diện người dùng của trang web chặn khi chờ dữ liệu đầu vào. Điều này rất quan trọng vì có thể nhận được dữ liệu nối tiếp bất cứ lúc nào và cần có cách để nghe dữ liệu đó.

Để mở cổng nối tiếp, trước tiên, hãy truy cập vào đối tượng SerialPort. Do đó, bạn có thể nhắc người dùng chọn một cổng nối tiếp bằng cách gọi navigator.serial.requestPort() để phản hồi cử chỉ của người dùng (chẳng hạn như chạm hoặc nhấp chuột) hoặc chọn một cổng từ navigator.serial.getPorts() để trả về danh sách cổng nối tiếp mà trang web đã được cấp quyền truy cập.

document.querySelector('button').addEventListener('click', async () => {
  // Prompt user to select any serial port.
  const port = await navigator.serial.requestPort();
});
// Get all serial ports the user has previously granted the website access to.
const ports = await navigator.serial.getPorts();

Hàm navigator.serial.requestPort() lấy một giá trị cố định đối tượng không bắt buộc để xác định các bộ lọc. Chúng được dùng để so khớp mọi thiết bị nối tiếp được kết nối qua USB với nhà cung cấp USB bắt buộc (usbVendorId) và giá trị nhận dạng sản phẩm USB (usbProductId) không bắt buộc.

// Filter on devices with the Arduino Uno USB Vendor/Product IDs.
const filters = [
  { usbVendorId: 0x2341, usbProductId: 0x0043 },
  { usbVendorId: 0x2341, usbProductId: 0x0001 }
];

// Prompt user to select an Arduino Uno device.
const port = await navigator.serial.requestPort({ filters });

const { usbProductId, usbVendorId } = port.getInfo();
Ảnh chụp màn hình lời nhắc cổng nối tiếp trên trang web
Lời nhắc người dùng chọn micro:bit của BBC

Việc gọi requestPort() sẽ nhắc người dùng chọn một thiết bị và trả về đối tượng SerialPort. Sau khi bạn có đối tượng SerialPort, việc gọi port.open() với tốc độ baud mong muốn sẽ mở cổng nối tiếp. Thành phần từ điển baudRate chỉ định tốc độ gửi dữ liệu qua một đường nối tiếp. Giá trị này được biểu thị bằng đơn vị bit/giây (bps). Hãy xem tài liệu về thiết bị của bạn để biết giá trị chính xác vì tất cả dữ liệu bạn gửi và nhận sẽ bị vô nghĩa nếu được chỉ định không chính xác. Đối với một số thiết bị USB và Bluetooth mô phỏng cổng nối tiếp, giá trị này có thể được đặt an toàn thành bất kỳ giá trị nào vì quá trình mô phỏng sẽ bỏ qua giá trị này.

// Prompt user to select any serial port.
const port = await navigator.serial.requestPort();

// Wait for the serial port to open.
await port.open({ baudRate: 9600 });

Bạn cũng có thể chỉ định tuỳ chọn bất kỳ bên dưới khi mở cổng nối tiếp. Các tuỳ chọn này là không bắt buộc và có giá trị mặc định thuận tiện.

  • dataBits: Số bit dữ liệu trên mỗi khung (7 hoặc 8).
  • stopBits: Số bit dừng ở cuối khung (1 hoặc 2).
  • parity: Chế độ cân bằng ("none", "even" hoặc "odd").
  • bufferSize: Kích thước của vùng đệm đọc và ghi cần được tạo (phải nhỏ hơn 16 MB).
  • flowControl: Chế độ kiểm soát luồng ("none" hoặc "hardware").

Đọc từ cổng nối tiếp

Luồng đầu vào và đầu ra trong API Web Serial do API Luồng xử lý.

Sau khi thiết lập kết nối cổng nối tiếp, các thuộc tính readablewritable của đối tượng SerialPort sẽ trả về một ReadableStream và một WritableStream. Các đối tượng này sẽ được dùng để nhận dữ liệu từ và gửi dữ liệu đến thiết bị nối tiếp. Cả hai đều sử dụng thực thể Uint8Array để chuyển dữ liệu.

Khi dữ liệu mới đến từ thiết bị nối tiếp, port.readable.getReader().read() sẽ trả về hai thuộc tính không đồng bộ: value và một boolean done. Nếu done là đúng, thì cổng nối tiếp đã đóng hoặc không có thêm dữ liệu nào vào. Việc gọi port.readable.getReader() sẽ tạo một trình đọc và khoá readable với trình đọc đó. Khi readable bị khoá, bạn không thể đóng cổng nối tiếp.

const reader = port.readable.getReader();

// Listen to data coming from the serial device.
while (true) {
  const { value, done } = await reader.read();
  if (done) {
    // Allow the serial port to be closed later.
    reader.releaseLock();
    break;
  }
  // value is a Uint8Array.
  console.log(value);
}

Một số lỗi đọc cổng nối tiếp không nghiêm trọng có thể xảy ra trong một số điều kiện như tràn bộ đệm, lỗi lấy khung hình hoặc lỗi về tính tương đồng. Những vòng lặp đó được gửi dưới dạng ngoại lệ và có thể được phát hiện bằng cách thêm một vòng lặp khác lên trên vòng lặp trước đó để kiểm tra port.readable. Cách này hiệu quả vì miễn là lỗi không nghiêm trọng, thì một ReadableStream mới sẽ tự động được tạo. Nếu xảy ra lỗi nghiêm trọng, chẳng hạn như xoá thiết bị nối tiếp, thì port.readable sẽ trở thành giá trị rỗng.

while (port.readable) {
  const reader = port.readable.getReader();

  try {
    while (true) {
      const { value, done } = await reader.read();
      if (done) {
        // Allow the serial port to be closed later.
        reader.releaseLock();
        break;
      }
      if (value) {
        console.log(value);
      }
    }
  } catch (error) {
    // TODO: Handle non-fatal read error.
  }
}

Nếu thiết bị nối tiếp gửi lại văn bản, bạn có thể chuyển port.readable thông qua TextDecoderStream như minh hoạ dưới đây. TextDecoderStream là một luồng biến đổi lấy tất cả phân đoạn Uint8Array và chuyển đổi các phần đó thành chuỗi.

const textDecoder = new TextDecoderStream();
const readableStreamClosed = port.readable.pipeTo(textDecoder.writable);
const reader = textDecoder.readable.getReader();

// Listen to data coming from the serial device.
while (true) {
  const { value, done } = await reader.read();
  if (done) {
    // Allow the serial port to be closed later.
    reader.releaseLock();
    break;
  }
  // value is a string.
  console.log(value);
}

Bạn có thể kiểm soát cách phân bổ bộ nhớ khi đọc từ luồng bằng trình đọc "Mang về vùng đệm riêng". Gọi port.readable.getReader({ mode: "byob" }) để nhận giao diện ReadableStreamBYOBReader và cung cấp ArrayBuffer của riêng bạn khi gọi read(). Lưu ý rằng Web Serial API hỗ trợ tính năng này trong Chrome 106 trở lên.

try {
  const reader = port.readable.getReader({ mode: "byob" });
  // Call reader.read() to read data into a buffer...
} catch (error) {
  if (error instanceof TypeError) {
    // BYOB readers are not supported.
    // Fallback to port.readable.getReader()...
  }
}

Sau đây là ví dụ về cách sử dụng lại vùng đệm khỏi value.buffer:

const bufferSize = 1024; // 1kB
let buffer = new ArrayBuffer(bufferSize);

// Set `bufferSize` on open() to at least the size of the buffer.
await port.open({ baudRate: 9600, bufferSize });

const reader = port.readable.getReader({ mode: "byob" });
while (true) {
  const { value, done } = await reader.read(new Uint8Array(buffer));
  if (done) {
    break;
  }
  buffer = value.buffer;
  // Handle `value`.
}

Dưới đây là một ví dụ khác về cách đọc một lượng dữ liệu cụ thể qua cổng nối tiếp:

async function readInto(reader, buffer) {
  let offset = 0;
  while (offset < buffer.byteLength) {
    const { value, done } = await reader.read(
      new Uint8Array(buffer, offset)
    );
    if (done) {
      break;
    }
    buffer = value.buffer;
    offset += value.byteLength;
  }
  return buffer;
}

const reader = port.readable.getReader({ mode: "byob" });
let buffer = new ArrayBuffer(512);
// Read the first 512 bytes.
buffer = await readInto(reader, buffer);
// Then read the next 512 bytes.
buffer = await readInto(reader, buffer);

Ghi vào cổng nối tiếp

Để gửi dữ liệu đến một thiết bị nối tiếp, hãy truyền dữ liệu đến port.writable.getWriter().write(). Bạn cần gọi releaseLock() trên port.writable.getWriter() để đóng cổng nối tiếp sau này.

const writer = port.writable.getWriter();

const data = new Uint8Array([104, 101, 108, 108, 111]); // hello
await writer.write(data);


// Allow the serial port to be closed later.
writer.releaseLock();

Gửi văn bản đến thiết bị thông qua TextEncoderStream được chuyển đến port.writable như minh hoạ bên dưới.

const textEncoder = new TextEncoderStream();
const writableStreamClosed = textEncoder.readable.pipeTo(port.writable);

const writer = textEncoder.writable.getWriter();

await writer.write("hello");

Đóng cổng nối tiếp

port.close() đóng cổng nối tiếp nếu thành phần readablewritable đã được mở khoá, nghĩa là releaseLock() đã được gọi cho trình đọc và tác giả tương ứng.

await port.close();

Tuy nhiên, khi liên tục đọc dữ liệu từ một thiết bị nối tiếp thông qua vòng lặp, port.readable sẽ luôn bị khoá cho đến khi gặp lỗi. Trong trường hợp này, việc gọi reader.cancel() sẽ buộc reader.read() phân giải ngay lập tức bằng { value: undefined, done: true }, do đó cho phép vòng lặp gọi reader.releaseLock().

// Without transform streams.

let keepReading = true;
let reader;

async function readUntilClosed() {
  while (port.readable && keepReading) {
    reader = port.readable.getReader();
    try {
      while (true) {
        const { value, done } = await reader.read();
        if (done) {
          // reader.cancel() has been called.
          break;
        }
        // value is a Uint8Array.
        console.log(value);
      }
    } catch (error) {
      // Handle error...
    } finally {
      // Allow the serial port to be closed later.
      reader.releaseLock();
    }
  }

  await port.close();
}

const closedPromise = readUntilClosed();

document.querySelector('button').addEventListener('click', async () => {
  // User clicked a button to close the serial port.
  keepReading = false;
  // Force reader.read() to resolve immediately and subsequently
  // call reader.releaseLock() in the loop example above.
  reader.cancel();
  await closedPromise;
});

Việc đóng cổng nối tiếp sẽ phức tạp hơn khi sử dụng luồng biến đổi. Gọi reader.cancel() như trước. Sau đó, hãy gọi writer.close()port.close(). Hành động này sẽ tạo lỗi thông qua các luồng biến đổi đến cổng nối tiếp cơ bản. Vì việc truyền lỗi không xảy ra ngay lập tức, nên bạn cần sử dụng readableStreamClosedwritableStreamClosed hứa hẹn được tạo trước đó để phát hiện thời điểm port.readableport.writable được mở khoá. Việc huỷ reader sẽ khiến luồng dữ liệu bị huỷ; đó là lý do bạn phải phát hiện và bỏ qua lỗi phát sinh.

// With transform streams.

const textDecoder = new TextDecoderStream();
const readableStreamClosed = port.readable.pipeTo(textDecoder.writable);
const reader = textDecoder.readable.getReader();

// Listen to data coming from the serial device.
while (true) {
  const { value, done } = await reader.read();
  if (done) {
    reader.releaseLock();
    break;
  }
  // value is a string.
  console.log(value);
}

const textEncoder = new TextEncoderStream();
const writableStreamClosed = textEncoder.readable.pipeTo(port.writable);

reader.cancel();
await readableStreamClosed.catch(() => { /* Ignore the error */ });

writer.close();
await writableStreamClosed;

await port.close();

Theo dõi trạng thái kết nối và ngắt kết nối

Nếu thiết bị USB cung cấp cổng nối tiếp, thì thiết bị đó có thể được kết nối hoặc ngắt kết nối khỏi hệ thống. Khi đã được cấp quyền truy cập vào cổng nối tiếp, trang web sẽ theo dõi các sự kiện connectdisconnect.

navigator.serial.addEventListener("connect", (event) => {
  // TODO: Automatically open event.target or warn user a port is available.
});

navigator.serial.addEventListener("disconnect", (event) => {
  // TODO: Remove |event.target| from the UI.
  // If the serial port was opened, a stream error would be observed as well.
});

Xử lý tín hiệu

Sau khi thiết lập kết nối cổng nối tiếp, bạn có thể truy vấn và đặt các tín hiệu do cổng nối tiếp hiển thị một cách rõ ràng để phát hiện thiết bị và kiểm soát luồng. Các tín hiệu này được định nghĩa là giá trị boolean. Ví dụ: một số thiết bị như Arduino sẽ chuyển sang chế độ lập trình nếu tín hiệu Data Terminal Sẵn sàng (DTR) được bật/tắt.

Việc đặt tín hiệu đầu ra và nhận tín hiệu đầu vào được thực hiện lần lượt bằng cách gọi port.setSignals()port.getSignals(). Hãy xem ví dụ về cách sử dụng ở bên dưới.

// Turn off Serial Break signal.
await port.setSignals({ break: false });

// Turn on Data Terminal Ready (DTR) signal.
await port.setSignals({ dataTerminalReady: true });

// Turn off Request To Send (RTS) signal.
await port.setSignals({ requestToSend: false });
const signals = await port.getSignals();
console.log(`Clear To Send:       ${signals.clearToSend}`);
console.log(`Data Carrier Detect: ${signals.dataCarrierDetect}`);
console.log(`Data Set Ready:      ${signals.dataSetReady}`);
console.log(`Ring Indicator:      ${signals.ringIndicator}`);

Cải tiến các sự kiện phát trực tiếp

Khi nhận dữ liệu từ thiết bị nối tiếp, bạn không nhất thiết phải nhận được tất cả dữ liệu cùng một lúc. Tệp này có thể được phân đoạn tuỳ ý. Để biết thêm thông tin, hãy xem bài viết Khái niệm về API luồng.

Để xử lý vấn đề này, bạn có thể sử dụng một số luồng biến đổi tích hợp sẵn, chẳng hạn như TextDecoderStream hoặc tạo luồng biến đổi riêng để cho phép phân tích cú pháp luồng đến và trả về dữ liệu đã phân tích cú pháp. Luồng biến đổi nằm giữa thiết bị nối tiếp và vòng lặp đọc sử dụng luồng. Hàm này có thể áp dụng một phép biến đổi tuỳ ý trước khi dữ liệu được sử dụng. Hãy coi đây là một dây chuyền tập hợp: khi một tiện ích xuất hiện trong dòng, mỗi bước trong dòng sẽ sửa đổi tiện ích để đến thời điểm đến đích cuối cùng, đó là một tiện ích có đầy đủ chức năng.

Ảnh chụp một nhà máy máy bay
Nhà máy máy bay không người lái trong Thế chiến II Castle Bromwich

Ví dụ: hãy xem xét cách tạo lớp luồng biến đổi sử dụng một luồng và phân đoạn luồng đó dựa trên điểm ngắt dòng. Phương thức transform() của mẫu này được gọi mỗi khi luồng nhận được dữ liệu mới. Ứng dụng này có thể thêm dữ liệu vào hàng đợi hoặc lưu dữ liệu để sử dụng sau này. Phương thức flush() được gọi khi luồng đóng và xử lý mọi dữ liệu chưa được xử lý.

Để sử dụng lớp luồng biến đổi, bạn cần chuyển một luồng đến thông qua lớp đó. Trong ví dụ về mã thứ ba ở phần Đọc từ cổng nối tiếp, luồng đầu vào ban đầu chỉ được truyền qua TextDecoderStream, vì vậy, chúng ta cần gọi pipeThrough() để chuyển luồng đầu vào qua LineBreakTransformer mới.

class LineBreakTransformer {
  constructor() {
    // A container for holding stream data until a new line.
    this.chunks = "";
  }

  transform(chunk, controller) {
    // Append new chunks to existing chunks.
    this.chunks += chunk;
    // For each line breaks in chunks, send the parsed lines out.
    const lines = this.chunks.split("\r\n");
    this.chunks = lines.pop();
    lines.forEach((line) => controller.enqueue(line));
  }

  flush(controller) {
    // When the stream is closed, flush any remaining chunks out.
    controller.enqueue(this.chunks);
  }
}
const textDecoder = new TextDecoderStream();
const readableStreamClosed = port.readable.pipeTo(textDecoder.writable);
const reader = textDecoder.readable
  .pipeThrough(new TransformStream(new LineBreakTransformer()))
  .getReader();

Để gỡ lỗi các sự cố giao tiếp với thiết bị nối tiếp, hãy sử dụng phương thức tee() của port.readable để phân tách các luồng đến hoặc từ thiết bị nối tiếp. Bạn có thể sử dụng 2 luồng được tạo độc lập và điều này cho phép bạn in một luồng ra bảng điều khiển để kiểm tra.

const [appReadable, devReadable] = port.readable.tee();

// You may want to update UI with incoming data from appReadable
// and log incoming data in JS console for inspection from devReadable.

Thu hồi quyền truy cập vào cổng nối tiếp

Trang web có thể xoá các quyền truy cập vào cổng nối tiếp mà trang web không muốn giữ lại nữa bằng cách gọi forget() trên thực thể SerialPort. Ví dụ: đối với một ứng dụng web giáo dục dùng trên một máy tính dùng chung có nhiều thiết bị, việc tích luỹ một lượng lớn quyền do người dùng tạo sẽ tạo ra trải nghiệm kém cho người dùng.

// Voluntarily revoke access to this serial port.
await port.forget();

forget() có trong Chrome 103 trở lên, hãy kiểm tra xem tính năng này có được hỗ trợ trong các phần sau đây hay không:

if ("serial" in navigator && "forget" in SerialPort.prototype) {
  // forget() is supported.
}

Mẹo cho nhà phát triển

Bạn có thể dễ dàng gỡ lỗi Web Serial API trong trang nội bộ, about://device-log, nơi bạn có thể xem tất cả các sự kiện liên quan đến thiết bị nối tiếp ở một nơi duy nhất.

Ảnh chụp màn hình trang nội bộ để gỡ lỗi API Web Serial.
Trang nội bộ trong Chrome để gỡ lỗi Web Serial API.

Lớp học lập trình

Trong lớp học lập trình dành cho Nhà phát triển của Google, bạn sẽ sử dụng Web Serial API để tương tác với bảng BBC micro:bit để hiển thị hình ảnh trên ma trận đèn LED 5x5.

Hỗ trợ trình duyệt

Web Serial API có trên tất cả các nền tảng máy tính (ChromeOS, Linux, macOS và Windows) trong Chrome 89.

Vải polyfill

Trên Android, bạn có thể hỗ trợ cổng nối tiếp dựa trên USB bằng cách sử dụng API WebUSB và polyfill API nối tiếp. Tính năng polyfill này chỉ sử dụng được trên phần cứng và nền tảng mà thiết bị có thể truy cập được thông qua API WebUSB vì trình điều khiển thiết bị tích hợp chưa xác nhận quyền sở hữu loại polyfill này.

Mức độ bảo mật và quyền riêng tư

Tác giả của bản đặc tả kỹ thuật đã thiết kế và triển khai Web Serial API theo các nguyên tắc cốt lõi được xác định trong Kiểm soát quyền truy cập vào các tính năng nền tảng web mạnh mẽ, bao gồm quyền kiểm soát của người dùng, độ trong suốt và tính công thái học. Khả năng sử dụng API này chủ yếu chịu sự kiểm soát của một mô hình cấp quyền chỉ cấp quyền truy cập vào một thiết bị nối tiếp tại một thời điểm. Để phản hồi lời nhắc của người dùng, người dùng phải thực hiện các bước chủ động để chọn một thiết bị nối tiếp cụ thể.

Để tìm hiểu các đánh đổi về bảo mật, hãy xem các phần bảo mậtquyền riêng tư trong Công cụ giải thích về API Web Serial.

Ý kiến phản hồi

Nhóm Chrome rất muốn biết suy nghĩ và trải nghiệm của bạn với Web Serial API.

Cho chúng tôi biết về thiết kế của API

Có vấn đề nào về API không hoạt động như mong đợi không? Hay có phương thức hoặc thuộc tính nào bị thiếu mà bạn cần triển khai ý tưởng của mình không?

Gửi vấn đề về thông số kỹ thuật trên kho lưu trữ GitHub về Web Serial API hoặc thêm ý tưởng vào vấn đề hiện có.

Báo cáo sự cố với quá trình triển khai

Bạn có phát hiện thấy lỗi khi triển khai Chrome không? Hay cách triển khai có khác với thông số kỹ thuật không?

Gửi lỗi tại https://new.crbug.com. Hãy nhớ cung cấp nhiều chi tiết nhất có thể, cung cấp hướng dẫn đơn giản để tái tạo lỗi và đặt Thành phần thành Blink>Serial. Sự kiện không mong muốn hoạt động hiệu quả để chia sẻ các bản sửa lại nhanh chóng và dễ dàng.

Hiển thị sự ủng hộ

Bạn có định sử dụng API Web Serial không? Sự hỗ trợ công khai của bạn giúp nhóm Chrome ưu tiên các tính năng và cho các nhà cung cấp trình duyệt khác biết tầm quan trọng của việc hỗ trợ họ.

Hãy gửi một bài đăng trên Twitter tới @ChromiumDev kèm theo hashtag #SerialAPI và cho chúng tôi biết vị trí cũng như cách bạn sử dụng bài đăng này.

Các đường liên kết hữu ích

Bản thu thử

Xác nhận

Cảm ơn Reilly GrantJoe Medley đã đánh giá bài viết này. Ảnh chụp nhà máy máy bay của Birmingham Bảo tàng Trust trên Unsplash.