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

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

François Beaufort
François Beaufort

מה זה Web Serial API?

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

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

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

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

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

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

במקרים מסוימים, אתרים מתקשרים עם המכשיר באמצעות אפליקציית סוכן שהמשתמשים התקינו באופן ידני. באחרים, האפליקציה מועברת באפליקציה ארוזה דרך מסגרת כמו 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 micro:bit

הפונקציה requestPort() מבקשת מהמשתמש לבחור מכשיר ומחזירה אובייקט SerialPort. אחרי שיש לכם אובייקט SerialPort, קריאה ל-port.open() עם קצב העברת הנתונים הרצוי תפתח את היציאה הטורית. הערך baudRate dictionary member מציין את מהירות שליחת הנתונים בקו סדרתי. הערך הזה מוצג ביחידות של סיביות לשנייה (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 הוא transform stream שמחלץ את כל חלקי ה-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);
}

אתם יכולים לקבוע איך הזיכרון יוקצה כשקוראים מהזרם באמצעות קורא מסוג Bring Your Own Buffer (הבאת מאגר משלך). מתקשרים אל 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 הם unlocked, כלומר בוצעה קריאה ל-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 יעברו למצב תכנות אם האות Data Terminal Ready ‏ (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 או ליצור זרם טרנספורמציה משלכם שיאפשר לכם לנתח את הזרם הנכנס ולהחזיר נתונים מנותחים. הזרם של הטרנספורמציה נמצא בין המכשיר הסדרתי לבין לולאת הקריאה שצורכת את הזרם. היא יכולה להחיל טרנספורמציה שרירותית לפני שהנתונים נצרכים. אפשר לדמיין את זה כמו פס ייצור: כשפריט ווידג'ט מגיע לפס, כל שלב בפס משנה את הווידג'ט, כך שכשהוא מגיע ליעד הסופי שלו, הוא ווידג'ט שמתפקד באופן מלא.

תמונה של מפעל מטוסים
World War II Castle Bromwich Aeroplane Factory

לדוגמה, נניח שרוצים ליצור מחלקה של זרם טרנספורמציה שצורכת זרם ומחלקת אותו לחלקים על סמך מעברי שורה. השיטה 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.

תמיכה בדפדפנים

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

פוליפיל

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

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

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

כדי להבין את ההשלכות על האבטחה, כדאי לעיין בסעיפים security (אבטחה) ו-privacy (פרטיות) של Web Serial API Explainer.

משוב

צוות Chrome ישמח לשמוע את דעתכם על Web Serial API.

נשמח לקבל מידע על עיצוב ה-API

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

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

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

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

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

תמיכה ביוצרים

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

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

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

הדגמות

תודות

תודה לריילי גרנט ולג'ו מדלי על הביקורות שלהם על המאמר הזה. תמונה של מפעל מטוסים מאת Birmingham Museums Trust ב-Unsplash.