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

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

François Beaufort
François Beaufort

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

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

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

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

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

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

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

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

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

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

שימוש ב-Web Serial API

זיהוי תכונות

כדי לבדוק אם יש תמיכה ב-Web Series 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 מציין את מהירות השליחה של הנתונים בקו טורי. הוא מחושב ביחידות של סיביות לשנייה (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").

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

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

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

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

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

Codelab

בcodelab של Google Developer, תשתמשו ב-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.

משוב

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

תיאור של עיצוב ה-API

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

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

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

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

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

הצגת תמיכה

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

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

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

הדגמות

אישורים

תודה לReilly Grant ולJoe Medley שהביקורות שלהם על המאמר הזה. צילום של מפעל לייצור מטוסים מאת Birmingham Museums Trust ב-Unsplash.