از یک پورت سریال بخوانید و بنویسید

Web Serial API به وب سایت ها اجازه می دهد تا با دستگاه های سریال ارتباط برقرار کنند.

فرانسوا بوفور
François Beaufort

Web Serial API چیست؟

پورت سریال یک رابط ارتباطی دو طرفه است که امکان ارسال و دریافت اطلاعات بایت به بایت را فراهم می کند.

Web Serial API راهی را برای وب‌سایت‌ها فراهم می‌کند تا از یک دستگاه سریال با جاوا اسکریپت بخوانند و روی آن بنویسند. دستگاه های سریال یا از طریق یک پورت سریال در سیستم کاربر یا از طریق دستگاه های USB و بلوتوث قابل جابجایی که یک پورت سریال را شبیه سازی می کنند متصل می شوند.

به عبارت دیگر، 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();
اسکرین شات از دستور پورت سریال در یک وب سایت
درخواست کاربر برای انتخاب micro:bit BBC

فراخوانی requestPort() از کاربر می خواهد که یک دستگاه را انتخاب کند و یک شی SerialPort را برمی گرداند. هنگامی که یک شی SerialPort دارید، با فراخوانی port.open() با نرخ باود مورد نظر، پورت سریال باز می شود. عضو فرهنگ لغت baudRate مشخص می کند که داده ها با چه سرعتی از طریق یک خط سریال ارسال می شوند. در واحد بیت در ثانیه (bps) بیان می شود. اسناد دستگاه خود را از نظر مقدار صحیح بررسی کنید، زیرا در صورت تعیین نادرست، تمام داده‌هایی که ارسال و دریافت می‌کنید، بیهوده خواهند بود. برای برخی از دستگاه‌های USB و بلوتوث که یک پورت سریال را شبیه‌سازی می‌کنند، ممکن است این مقدار به‌طور ایمن روی هر مقداری تنظیم شود زیرا توسط شبیه‌سازی نادیده گرفته می‌شود.

// 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 : اندازه بافرهای خواندن و نوشتن که باید ایجاد شود (باید کمتر از 16 مگابایت باشد).
  • flowControl : حالت کنترل جریان (یا "none" یا "hardware" ).

از پورت سریال بخوانید

جریان‌های ورودی و خروجی در Web Serial API توسط Streams API مدیریت می‌شوند.

پس از برقراری اتصال پورت سریال، ویژگی های readable و writable از شی SerialPort یک ReadableStream و یک WritableStream را برمی گرداند. آن ها برای دریافت داده ها و ارسال داده ها به دستگاه سریال استفاده می شوند. هر دو از نمونه های Uint8Array برای انتقال داده استفاده می کنند.

هنگامی که داده های جدید از دستگاه سریال می رسد، port.readable.getReader().read() دو ویژگی را به صورت ناهمزمان برمی گرداند: value و یک Boolean done . اگر done درست باشد، پورت سریال بسته شده است یا دیگر داده‌ای وارد نمی‌شود. فراخوانی 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 پوچ می شود.

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

هنگام خواندن از جریان با استفاده از خواننده "Bring Your Own Buffer" می توانید نحوه تخصیص حافظه را کنترل کنید. برای دریافت رابط ReadableStreamBYOBReader و ارائه ArrayBuffer خود هنگام فراخوانی read() با port.readable.getReader({ mode: "byob" }) تماس بگیرید. توجه داشته باشید که 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.
});

کنترل سیگنال ها

پس از برقراری اتصال پورت سریال، می‌توانید به‌صراحت سیگنال‌هایی را که توسط پورت سریال در معرض دید قرار می‌گیرند را برای تشخیص دستگاه و کنترل جریان تنظیم کنید. این سیگنال ها به عنوان مقادیر بولی تعریف می شوند. به عنوان مثال، برخی از دستگاه‌ها مانند آردوینو در صورتی که سیگنال دیتا ترمینال آماده (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}`);

دگرگونی جریان ها

وقتی داده‌ها را از دستگاه سریال دریافت می‌کنید، لزوماً همه داده‌ها را یکجا دریافت نمی‌کنید. ممکن است خودسرانه خرد شود. برای اطلاعات بیشتر، مفاهیم API Streams را ببینید.

برای مقابله با این، می توانید از برخی جریان های تبدیل داخلی مانند TextDecoderStream استفاده کنید یا جریان تبدیل خود را ایجاد کنید که به شما امکان می دهد جریان ورودی را تجزیه کنید و داده های تجزیه شده را برگردانید. جریان تبدیل بین دستگاه سریال و حلقه خواندن که جریان را مصرف می کند قرار می گیرد. می تواند قبل از مصرف داده ها یک تبدیل دلخواه اعمال کند. به آن مانند یک خط مونتاژ فکر کنید: وقتی یک ویجت از خط پایین می آید، هر مرحله از خط ویجت را تغییر می دهد، به طوری که زمانی که به مقصد نهایی خود می رسد، یک ویجت کاملاً کارآمد است.

عکس کارخانه هواپیما سازی
کارخانه هواپیماسازی قلعه برومویچ جنگ جهانی دوم

به عنوان مثال، نحوه ایجاد یک کلاس جریان تبدیل را در نظر بگیرید که یک جریان را مصرف کرده و آن را بر اساس شکست های خط تکه تکه می کند. هر بار که داده های جدیدی توسط جریان دریافت می شود، متد transform() آن فراخوانی می شود. می تواند داده ها را در صف قرار دهد یا آن را برای بعد ذخیره کند. متد flush() زمانی فراخوانی می‌شود که جریان بسته شود و هر داده‌ای را که هنوز پردازش نشده است کنترل می‌کند.

برای استفاده از کلاس تبدیل جریان، باید یک جریان ورودی را از طریق آن لوله کنید. در مثال کد سوم تحت عنوان Read from a serial port ، جریان ورودی اصلی فقط از طریق 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 در کروم با صفحه داخلی، about://device-log که در آن می توانید همه رویدادهای مربوط به دستگاه سریال را در یک مکان مشاهده کنید، آسان است.

اسکرین شات از صفحه داخلی برای رفع اشکال Web Serial API.
صفحه داخلی در کروم برای رفع اشکال Web Serial API.

Codelab

در لبه کد برنامه‌نویس Google ، از Web Serial API برای تعامل با برد micro:bit BBC استفاده می‌کنید تا تصاویر را روی ماتریس LED 5x5 آن نشان دهید.

پشتیبانی از مرورگر

Web Serial API در تمام پلتفرم‌های دسکتاپ (ChromeOS، Linux، macOS و Windows) در Chrome 89 در دسترس است.

پلی پر

در اندروید، پشتیبانی از پورت های سریال مبتنی بر USB با استفاده از WebUSB API و Serial API polyfill امکان پذیر است. این polyfill محدود به سخت‌افزار و پلتفرم‌هایی است که دستگاه از طریق WebUSB API قابل دسترسی است، زیرا توسط درایور دستگاه داخلی ادعا نشده است.

امنیت و حریم خصوصی

نویسندگان مشخصات API سریال وب را با استفاده از اصول اصلی تعریف شده در کنترل دسترسی به ویژگی‌های قدرتمند پلتفرم وب ، از جمله کنترل کاربر، شفافیت، و ارگونومی طراحی و پیاده‌سازی کرده‌اند. توانایی استفاده از این API در درجه اول توسط یک مدل مجوز که دسترسی تنها به یک دستگاه سریال را در یک زمان اعطا می کند، محدود شده است. در پاسخ به درخواست کاربر، کاربر باید اقدامات فعالی را برای انتخاب یک دستگاه سریال خاص انجام دهد.

برای درک معاوضه‌های امنیتی، بخش‌های امنیت و حریم خصوصی Web Serial API Explainer را بررسی کنید.

بازخورد

تیم Chrome مایل است درباره افکار و تجربیات شما درباره Web Serial API بشنوند.

در مورد طراحی API به ما بگویید

آیا چیزی در مورد API وجود دارد که مطابق انتظار کار نمی کند؟ یا آیا روش ها یا ویژگی هایی وجود دارد که برای اجرای ایده خود به آنها نیاز دارید؟

یک مشکل مشخصات را در مخزن Web Serial API GitHub ثبت کنید یا افکار خود را به یک مشکل موجود اضافه کنید.

گزارش مشکل در اجرا

آیا اشکالی در پیاده سازی کروم پیدا کردید؟ یا اجرا با مشخصات متفاوت است؟

یک اشکال را در https://new.crbug.com ثبت کنید. اطمینان حاصل کنید که تا جایی که می توانید جزئیات را وارد کنید، دستورالعمل های ساده ای را برای بازتولید اشکال ارائه دهید، و Components را روی Blink>Serial تنظیم کنید. Glitch برای به اشتراک گذاری سریع و آسان تکرارها عالی عمل می کند.

نشان دادن پشتیبانی

آیا قصد دارید از Web Serial API استفاده کنید؟ پشتیبانی عمومی شما به تیم Chrome کمک می‌کند ویژگی‌ها را اولویت‌بندی کند و به سایر فروشندگان مرورگر نشان می‌دهد که چقدر حمایت از آنها ضروری است.

با استفاده از هشتگ #SerialAPI یک توییت به ChromiumDev@ ارسال کنید و به ما اطلاع دهید کجا و چگونه از آن استفاده می‌کنید.

لینک های مفید

دموها

قدردانی

از ریلی گرانت و جو مدلی برای بررسی این مقاله تشکر می کنیم. عکس کارخانه هواپیما توسط موزه‌های بیرمنگام Trust on Unsplash .