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

Web Serial 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 יעברו למצב תכנות אם האות 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}`);

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

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

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

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

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