קריאה וכתיבה מיציאה טורית

Web Serial API מאפשר לאתרים לתקשר עם מכשירים עם יציאה טורית.

François Beaufort
François Beaufort

מה זה Web Serial API?

יציאה טורית היא ממשק תקשורת דו-כיווני, שמאפשר לשלוח ולקבל נתונים בכל בייט.

Web Serial API מאפשר לאתרים לקרוא ולכתוב במכשיר טורי באמצעות JavaScript. מכשירים עם יציאה טורית מחוברים באמצעות יציאה טורית במערכת של המשתמש, או באמצעות מכשירי USB ו-Bluetooth נשלפים שמדמים יציאה טורית.

במילים אחרות, Web Serial API מקשר בין האינטרנט לעולם הפיזי בכך שהוא מאפשר לאתרים לתקשר עם מכשירים עם יציאה טורית, כמו מיקרו-בקרים ומדפסות תלת ממד.

ה-API הזה הוא גם נכס נלווה מעולה ל-WebUSB, כי מערכות ההפעלה מחייבות אפליקציות לתקשר עם יציאות טוריות מסוימות באמצעות ה-API הטורי שלהן ברמה גבוהה יותר, במקום דרך ה-USB API ברמה נמוכה.

תרחישים לדוגמה

במגזרים החינוכיים, החובבים והתעשייתיים, המשתמשים מחברים ציוד היקפי למחשבים שלהם. לרוב המכשירים האלה נשלטים על ידי מיקרו-בקרים באמצעות חיבור טורי שמשמש תוכנות בהתאמה אישית. חלק מהתוכנות המותאמות אישית לשליטה במכשירים האלה מבוססות על טכנולוגיית האינטרנט:

במקרים מסוימים, אתרים מתקשרים עם המכשיר באמצעות אפליקציית סוכן שהמשתמשים התקינו באופן ידני. במקרים אחרים, האפליקציה נשלחת באפליקציה ארוזה דרך framework כמו Electron. במקרים אחרים, המשתמש נדרש לבצע שלב נוסף, כמו העתקת אפליקציה מקומפלת למכשיר באמצעות כונן USB נייד.

בכל המקרים האלה, חוויית המשתמש תשתפר בזכות תקשורת ישירה בין האתר לבין המכשיר שבשליטתו.

הסטטוס הנוכחי

שלב סטטוס
1. יצירת הסבר הושלם
2. יצירת טיוטה ראשונית של מפרט הושלם
3. אוספים משוב וחוזרים על העיצוב הושלם
4. גרסת מקור לניסיון הושלם
5. הפעלה הושלם

שימוש ב-Web Serial API

זיהוי תכונות

כדי לבדוק אם יש תמיכה ב-Web Serial API, משתמשים בפקודה:

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

פתיחת יציאה טורית

ה-Web Serial API הוא אסינכרוני. כך, ממשק המשתמש של האתר לא ייחסם בזמן ההמתנה לקלט. יש בכך חשיבות גדולה כי אפשר לקבל נתונים טוריים בכל שלב, מה שמחייב דרך להאזין להם.

כדי לפתוח יציאה טורית, קודם צריך לגשת לאובייקט SerialPort. כדי לעשות את זה, אפשר לבקש מהמשתמש לבחור יציאה טורית אחת על ידי קריאה ל-navigator.serial.requestPort() בתגובה לפעולת משתמש, כמו לחיצה או לחיצה על העכבר, או לבחור יציאה מ-navigator.serial.getPorts() שמחזירה רשימה של יציאות טוריות שאליהן האתר קיבל גישה.

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();

הפונקציה navigator.serial.requestPort() מקבלת ליטרל אופציונלי של אובייקט שמגדיר מסננים. המזהה משמש להתאמה לכל מכשיר טורי שמחובר בחיבור USB עם ספק USB (usbVendorId) אופציונלי ומזהי מוצר אופציונליים ב-USB (usbProductId).

// 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();
צילום מסך של בקשה ליציאה טורית באתר
הודעת משתמש לבחירת מיקרו:ביט של BBC

קריאה ל-requestPort() מבקשת מהמשתמש לבחור מכשיר ומחזירה אובייקט SerialPort. אחרי שיוצרים אובייקט SerialPort, קריאה ל-port.open() עם קצב הבאוד הרצוי תפתח את היציאה הטורית. המינוי במילון baudRate מציין את מהירות השליחה של הנתונים בקו טורי. הוא מבוטא ביחידות של ביטים לשנייה (bps). כדאי לבדוק במסמכי התיעוד של המכשיר את הערך הנכון, כי כל הנתונים שנשלחים ומקבלים יהיו ג'יבריש אם תציינו זאת בצורה שגויה. במכשירי USB ו-Bluetooth מסוימים שמדמים יציאה טורית, אפשר להגדיר בבטחה את הערך הזה לכל ערך, כי האמולציה מתעלמת ממנו.

// 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 });

ניתן גם לציין כל אחת מהאפשרויות הבאות כשפותחים יציאה טורית. האפשרויות האלה אופציונליות ויש להן ערכי ברירת מחדל נוחים.

  • dataBits: מספר ביטים של נתונים לכל פריים (7 או 8).
  • stopBits: מספר ביטים של עצירות בסוף פריים (1 או 2).
  • parity: מצב ההתאמה ("none", "even" או "odd").
  • bufferSize: גודל מאגר הנתונים הזמני לקריאה ולכתיבה (חייב להיות קטן מ-16MB).
  • flowControl: מצב בקרת הזרימה ("none" או "hardware").

קריאה מיציאה טורית

זרמי הקלט והפלט ב-Web Serial API מטופלים על ידי Streams API.

אחרי יצירת החיבור ליציאה הטורית, המאפיינים readable ו-writable מהאובייקט SerialPort מחזירים ReadableStream ו-WritableStream. כך הם ישמשו לקבלת נתונים מהמכשיר הטורי ולשליחת הנתונים אליו. שניהם משתמשים במופעים של Uint8Array להעברת נתונים.

כשמגיעים נתונים חדשים מהמכשיר הטורי, port.readable.getReader().read() מחזיר שני מאפיינים באופן אסינכרוני: value וערך בוליאני done. אם הערך של done מוגדר כ-True, היציאה הטורית נסגרה או שלא מתקבלים יותר נתונים. קריאה ל-port.readable.getReader() יוצרת קורא ונועלת אליו את readable. כש-readable נעול, אי אפשר לסגור את היציאה הטורית.

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);
}

שגיאות קריאה לא קריטיות ביציאות טוריות יכולות להתרחש בתנאים מסוימים, כמו גלישת נתונים במאגר נתונים זמני, שגיאות פריים או שגיאות בהתאמה. התרחישים האלה מיושמים כחריגות, ואפשר לאתר אותם על ידי הוספת לולאה נוספת מעל ללופ הקודם שבודק את port.readable. זה עובד כי כל עוד השגיאות לא חמורות, נוצר באופן אוטומטי ReadableStream חדש. במקרה שמתרחשת שגיאה חמורה, כמו הסרת המכשיר הטורי, port.readable הופך ל-null.

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.
  }
}

אם המכשיר הסידורי שולח טקסט בחזרה, אפשר לחבר את port.readable בצינור דרך TextDecoderStream כמו שמתואר בהמשך. TextDecoderStream הוא זרם טרנספורמציה שלוקח את כל המקטעים Uint8Array וממיר אותם למחרוזות.

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);
}

בעזרת הקורא 'הבא את מאגר הנתונים שלך', תוכל לשלוט באופן הקצאת הזיכרון בזמן קריאה מהזרם. צריך להתקשר אל port.readable.getReader({ mode: "byob" }) כדי לקבל את הממשק של ReadableStreamBYOBReader ולספק ArrayBuffer משלכם במהלך הקריאה ל-read(). חשוב לדעת ש-Web Serial API תומך בתכונה הזו ב-Chrome מגרסה 106 ואילך.

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()...
  }
}

הנה דוגמה לשימוש חוזר במאגר הנתונים הזמני מתוך 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`.
}

הנה דוגמה נוספת שממחישה איך לקרוא כמות ספציפית של נתונים מיציאה טורית:

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);

כתיבה ליציאה טורית

כדי לשלוח נתונים למכשיר עם יציאה טורית, מעבירים את הנתונים אל port.writable.getWriter().write(). נדרשת קריאה אל releaseLock() ב-port.writable.getWriter() כדי שהיציאה הטורית תיסגר מאוחר יותר.

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();

אפשר לשלוח טקסט למכשיר באמצעות TextEncoderStream שמעביר את הקלט ל-port.writable כפי שמוצג בהמשך.

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

const writer = textEncoder.writable.getWriter();

await writer.write("hello");

סגירת יציאה טורית

השקע הטורי של port.close() נסגר אם החברים ב-readable וב-writable לא נעולים. כלומר, בוצעה קריאה ל-releaseLock() עבור הקורא והכותב המתאים.

await port.close();

עם זאת, כשקוראים נתונים באופן רציף ממכשיר עם לולאה, port.readable תמיד יינעל עד שתהיה שגיאה. במקרה כזה, קריאה ל-reader.cancel() תאלץ את reader.read() להיפתר באופן מיידי עם { value: undefined, done: true }, ולכן תאפשר ללולאה לקרוא ל-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;
});

הסגירה של יציאה טורית מורכבת יותר כשמשתמשים בטרנספורמציה של שידורים. התקשרות אל reader.cancel() כמו קודם. לאחר מכן צריך להתקשר אל writer.close() ואל port.close(). כך השגיאות מופצות דרך זרמי הטרנספורמציה אל היציאה הטורית הבסיסית. מכיוון שהפצת שגיאות לא מתרחשת באופן מיידי, צריך להשתמש בהבטחות readableStreamClosed ו-writableStreamClosed שנוצרו קודם לכן כדי לזהות את הנעילה של port.readable ו-port.writable. ביטול ה-reader יגרום לביטול השידור, לכן צריך לזהות את השגיאה שמתקבלת ולהתעלם ממנה.

// 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();

האזנה לחיבור ולניתוק

אם התקן USB מספק יציאה טורית, יכול להיות שאותו מכשיר מחובר או מנותק מהמערכת. כשמעניקים לאתר הרשאה לגשת ליציאה טורית, הוא צריך לעקוב אחרי האירועים של connect ושל disconnect.

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.
});

טיפול באותות

אחרי יצירת החיבור ליציאה הטורית, אפשר להריץ שאילתות ולהגדיר אותות שנחשפים על ידי היציאה הטורית באופן מפורש לצורך זיהוי המכשיר ובקרת הזרימה. האותות האלה מוגדרים כערכים בוליאניים. לדוגמה, מכשירים מסוימים כמו Arduino ייכנסו למצב תכנות אם האות 'מוכן למסוף נתונים' (DTR) מופעל.

כדי להגדיר אותות פלט ולקבל אותות קלט, קוראים ל-port.setSignals() ול-port.getSignals() בהתאמה. דוגמאות לשימוש מפורטות בהמשך.

// 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}`);

טרנספורמציה של זרמים

כשמקבלים נתונים מהמכשיר עם יציאה טורית, לא בהכרח מקבלים את כל הנתונים בבת אחת. יכול להיות שהוא מחולק באופן שרירותי. למידע נוסף, ראו קונספטים של Streams API.

כדי לטפל בבעיה, תוכלו להשתמש בכמה מקורות נתונים מובנים של טרנספורמציה, כמו TextDecoderStream, או ליצור מקור נתונים טרנספורמציה משלכם שיאפשר לנתח את מקור הנתונים הנכנס ולהחזיר נתונים מנותחים. זרם הטרנספורמציה שוכן בין המכשיר הטורי לבין לולאת הקריאה שצורכת את השידור. היא יכולה להחיל טרנספורמציה שרירותית לפני צריכת הנתונים. זהו מעין קו הרכבה: כאשר ווידג'ט יורד לאורך הקו, כל שלב בשורה משנה את הווידג'ט, כך שעד שהוא מגיע ליעדו הסופי, הוא כבר ווידג'ט שפועל באופן מלא.

תמונה של מפעל לייצור מטוסים
מפעל טיסנים בברומוויץ' במלחמת העולם השנייה

לדוגמה, כדאי לחשוב איך ליצור מחלקה של זרם טרנספורמציה שצורכת שידור ומפרקת אותו בהתאם למעברי שורה. השיטה transform() נקראת בכל פעם שנתונים חדשים מתקבלים בסטרימינג. הוא יכול להכניס את הנתונים לתור או לשמור אותם למועד מאוחר יותר. מתבצעת קריאה לשיטה flush() כשהשידור נסגר, והיא מטפלת בנתונים שעדיין לא עובדו.

על מנת להשתמש במחלקה של זרם הטרנספורמציה, עליך לנתב זרם נכנס דרכו. בדוגמת הקוד השלישית בקטע קריאה מיציאה טורית, זרם הקלט המקורי הוכנס רק דרך TextDecoderStream, כך שעלינו להפעיל את pipeThrough() כדי לחבר אותו דרך LineBreakTransformer החדשה שלנו.

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();

לניפוי באגים בבעיות בתקשורת של המכשיר עם יציאה טורית, יש להשתמש בשיטת tee() של port.readable כדי לפצל את השידורים אל המכשיר הסידורי או ממנו. אפשר לצרוך את שני השידורים בנפרד, כך שאפשר להדפיס אחד מהם למסוף לצורך בדיקה.

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.

שלילת הגישה ליציאה טורית

האתר יכול לנקות את הרשאות הגישה ליציאה טורית, והוא כבר לא מעוניין לשמור על ידי קריאה ל-forget() במכונה של SerialPort. לדוגמה, באפליקציית אינטרנט חינוכית שפועלת במחשב משותף עם מכשירים רבים, כמות גדולה של הרשאות שנוצרו על ידי משתמשים יוצרת חוויית משתמש גרועה.

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

מכיוון ש-forget() זמין ב-Chrome מגרסה 103 ואילך, כדאי לבדוק אם התכונה הזו נתמכת באמצעות הרכיבים הבאים:

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

טיפים למפתחים

אפשר לנפות באגים ב-Web Serial API ב-Chrome בקלות באמצעות הדף הפנימי, about://device-log שבו אפשר לראות את כל האירועים שקשורים למכשירים הטוריים במקום אחד.

צילום מסך של הדף הפנימי לניפוי באגים ב-Web Serial API.
דף פנימי ב-Chrome לניפוי באגים ב-Web Serial API.

Codelab

ב-Google Developer codelab, תשתמשו ב-Web Serial API כדי לקיים אינטראקציה עם לוח BBC micro:bit כדי להציג תמונות במטריצת LED 5x5.

תמיכת דפדפן

Web Serial API זמין בכל הפלטפורמות למחשבים (ChromeOS, Linux, macOS ו-Windows) ב-Chrome 89.

פוליפיל

ב-Android אפשר לתמוך ביציאות טוריות מבוססות USB באמצעות WebUSB API ו-Serial API polyfill. ה-Polyfill הזה מוגבל לחומרה ולפלטפורמות שבהן אפשר לגשת למכשיר דרך WebUSB API, כי לא נתבעה עליו בעלות על ידי מנהל התקן מובנה של המכשיר.

אבטחה ופרטיות

מחברי המפרט תכננו והטמיעו את ה-Web Serial API באמצעות עקרונות הליבה שהוגדרו במאמר שליטה בגישה לתכונות מתקדמות של פלטפורמת אינטרנט, כולל בקרת משתמשים, שקיפות וארגונומיה. היכולת להשתמש ב-API הזה מוגנת בעיקר על ידי מודל הרשאות שמעניק גישה רק למכשיר טורי אחד בכל פעם. בתגובה לבקשה מהמשתמש, המשתמש צריך לבצע את הצעדים הפעילים כדי לבחור מכשיר עם יציאה טורית מסוימת.

על מנת להבין את החסרונות של האבטחה, כדאי לעיין בקטעי אבטחה ופרטיות ב-Web Serial API Explainer.

משוב

הצוות של Chrome ישמח לשמוע מה דעתך על חוויית השימוש ב-Web Serial API.

לספר לנו על עיצוב ה-API

האם יש משהו ב-API שלא פועל כצפוי? או האם יש שיטות או מאפיינים חסרים שאתם צריכים כדי ליישם את הרעיון?

דיווח על בעיה במפרט במאגר GitHub API של Web Serial API או כותבים את הרעיונות שלכם לבעיה קיימת.

דיווח על בעיה בהטמעה

האם מצאת באג בהטמעה של Chrome? או שההטמעה שונה מהמפרט?

אפשר לדווח על באג בכתובת https://new.crbug.com. חשוב לכלול כמה שיותר פרטים, לספק הוראות פשוטות לשחזור הבאג ולהגדיר את הרכיבים ל-Blink>Serial. גליץ' הוא כלי מעולה לשיתוף גיבויים מהירים וקלים.

הבעת תמיכה

תכננת להשתמש ב-Web Serial API? התמיכה הציבורית עוזרת לצוות של Chrome לתעדף תכונות, ומראה לספקי דפדפנים אחרים עד כמה חשוב התמיכה בהן.

שלח ציוץ אל @ChromiumDev באמצעות ה-hashtag #SerialAPI וספר לנו איפה אתם משתמשים בו ואיך אתם משתמשים בו.

קישורים שימושיים

הדגמות

אישורים

תודה ל-Reilly Grant ול-Joe Medley על הביקורות ששלחו על המאמר הזה. תמונה של מפעל לייצור מטוסים מאת קרן המוזיאונים של ברמינגהאם ב-UnFlood.