11.11. ช่องสัญญาณ L2CAP

GATT เป็นโมเดลคีย์/ค่า การดำเนินการที่รองรับ (อ่าน, เขียน, แจ้งเตือน, ระบุ) จะส่งค่าสั้นๆ ได้ครั้งละหนึ่งค่า และเพย์โหลดสูงสุดที่รองรับคือขนาด MTU ที่ตกลงไว้ ซึ่งมีได้มากสุดไม่กี่ร้อยไบต์เท่านั้น วิธีนี้เหมาะกับการอ่านค่าจาก sensor, รีจิสเตอร์คำสั่ง, และสถานะแฟล็ก แต่จะทำงานไม่ดีเมื่อข้อมูลมีขนาดเป็นกิโลไบต์หรือเมกะไบต์ เนื่องจากการแบ่งข้อมูลขนาดใหญ่เป็นการเขียนเล็กๆ หลายร้อยครั้งสิ้นเปลืองรอบการสื่อสารโดยไม่จำเป็น เพราะวิทยุรับส่งข้อมูลได้เร็วกว่ามาก

สำหรับการส่งข้อมูลจำนวนมาก เช่น เฟรมที่ถ่ายจาก camera แล้วสตรีมไปยังโทรศัพท์, ภาพสำหรับอัปเดตผ่านอากาศ, หรือการส่งออกข้อมูลวัดผลเป็นชุด BLE มีเส้นทางทางเลือกที่เรียกว่า Logical Link Control and Adaptation Protocol หรือ L2CAP L2CAP อยู่ระหว่างชั้น link layer กับ GATT และช่วยให้แอปพลิเคชันสร้าง connection-oriented channel เฉพาะของตัวเองบนลิงก์วิทยุเดียวกันได้ ช่องสัญญาณนี้เป็นเส้นทางรับส่งไบต์แบบควบคุมเครดิต มี MTU ต่อแพ็กเกตขนาดใหญ่กว่ามาก และไม่มีการกรอบ GATT ในระหว่าง

11.11.1. เมื่อใดควรใช้ L2CAP

ช่องสัญญาณ L2CAP เป็นเครื่องมือที่เหมาะสมเมื่อ:

  • การส่งข้อมูลมีขนาดมากกว่าไม่กี่ร้อยไบต์

  • ทั้งสองฝั่งทราบว่าจะมีการใช้ช่องสัญญาณ L2CAP (ไม่ได้เปิดเผยในเพย์โหลดการโฆษณา ฝั่งลูกค้าต้องทราบหมายเลข protocol/service multiplexer หรือ PSM ของช่องสัญญาณจากแหล่งอื่น)

  • แอปพลิเคชันพร้อมที่จะสละความสะดวกของ GATT: ไม่มีการระบุตำแหน่งด้วย UUID ในตัว, ไม่มีการค้นพบโดยลูกค้าผ่านแอปมาตรฐาน, ไม่มีการแจ้งเตือน

กรณีที่พบบ่อยที่สุดในแอปพลิเคชันที่ใช้ aioble คือการเคลื่อนย้ายบลอบข้อมูลไบนารีระหว่างซอฟต์แวร์สองชิ้นที่ทั้งคู่รู้จักข้อตกลง PSM เช่น โปรโตคอล camera-to-phone แบบกำหนดเอง, กล้อง openmv คู่หนึ่งที่สื่อสารกัน, หรือเส้นทางอัปเดตเฟิร์มแวร์ภายใน GATT service ของ peripheral

สำหรับสิ่งอื่นๆ ทั้งหมด ให้ใช้ GATT ต่อไป สถานะสั้นๆ, รีจิสเตอร์ควบคุม, การอ่านค่า sensor ล้วนเหมาะกับ characteristic

11.11.2. การสร้างช่องสัญญาณ

L2CAP ทำงาน บน aioble.DeviceConnection ที่มีอยู่แล้ว ดังนั้นขั้นตอนการค้นพบ/โฆษณา/เชื่อมต่อฝั่ง GAP จะเหมือนกันกับ GATT ทุกประการ เมื่อทั้งสองฝั่งมีการเชื่อมต่อแล้ว ฝั่งหนึ่งจะรับฟังบน PSM และอีกฝั่งจะเชื่อมต่อเข้ามา

PSM คือจำนวนเต็มขนาดเล็ก Bluetooth SIG สงวนช่วงล่างไว้สำหรับใช้งานมาตรฐาน (0x0001-0x007F) สำหรับช่องสัญญาณเฉพาะแอปพลิเคชัน ให้ใช้ตัวเลขจากช่วงไดนามิก (0x0080-0x00FF สำหรับ PSM คงที่, 0x0040 เป็นต้นไปมักว่างสำหรับใช้งานแบบกำหนดเอง) ทั้งสองฝั่งต้องตกลงค่านี้ล่วงหน้า

MTU บนช่องสัญญาณ L2CAP คือ SDU (Service Data Unit) สูงสุดที่แต่ละฝั่งจะส่งในการเรียก send() ครั้งเดียว ซึ่งต่างจาก BLE link MTU โดย Aioble จะแบ่งเพย์โหลดที่ใหญ่กว่าโดยอัตโนมัติ BLE host ของ camera กำหนด L2CAP MTU ไว้ที่ 1017 ไบต์ ค่า 512 เป็นค่าเริ่มต้นที่เหมาะสมซึ่งเหลือพื้นที่ไว้ทั้งสองฝั่งโดยไม่สิ้นเปลือง RAM

ฝั่งผู้รับฟัง (เช่น camera ในฐานะ peripheral):

async def serve_l2cap(connection, image_bytes):
    channel = await connection.l2cap_accept(psm=0x80, mtu=512)
    async with channel:
        # image_bytes is a bytearray -- e.g. csi0.snapshot().bytearray()
        # or a compressed JPEG buffer. send() fragments into MTU-sized
        # chunks automatically and awaits flow-control credits between.
        await channel.send(image_bytes)
        await channel.flush()

ฝั่งผู้เชื่อมต่อ (เช่น โทรศัพท์หรือ central):

async def open_l2cap(connection, total_bytes):
    channel = await connection.l2cap_connect(psm=0x80, mtu=512)
    async with channel:
        image_bytes = bytearray(total_bytes)
        view = memoryview(image_bytes)
        received = 0
        while received < total_bytes:
            n = await channel.recvinto(view[received:])
            if n == 0:
                break
            received += n
        return image_bytes

l2cap_accept() จะบล็อกจนกว่า peer จะเชื่อมต่อ (หรือ timeout_ms หมดเวลา) ส่วน l2cap_connect() จะบล็อกจนกว่าผู้รับฟังจะยอมรับ (หรือล้มเหลว) ทั้งคู่จะคืนค่าเป็น aioble.L2CAPChannel ซึ่งเป็น async context manager ที่จะปิดช่องสัญญาณเมื่อออกจาก block

11.11.3. การส่งและรับข้อมูล

การดำเนินการหลักสองอย่างบนช่องสัญญาณคือ send() (เขียนไบต์ไปยัง peer) และ recvinto() (อ่านเข้าบัฟเฟอร์ที่จัดสรรไว้ล่วงหน้า) ทั้งคู่เป็น coroutine

  • send() จะแบ่งบัฟเฟอร์เป็นชิ้นขนาด MTU และรอ link-layer flow-control credits ระหว่างชิ้น การส่งข้อมูลยาวคือ await เดียวจากมุมมองของแอปพลิเคชัน แต่ภายในอาจส่งคิวแพ็กเกตหลายชุดและหยุดชั่วคราวเมื่อ receive credits ของ peer หมด

  • recvinto() จะเติมข้อมูลที่พร้อมใช้งาน (สูงสุดถึง MTU ของช่องสัญญาณ) ลงในบัฟเฟอร์ที่ส่งเข้ามา และคืนค่าจำนวนไบต์ จะรอหากไม่มีข้อมูลพร้อม

  • available() คืนค่า True แบบซิงโครนัสหากมีข้อมูลในบัฟเฟอร์พร้อมใช้งาน ซึ่งมีประโยชน์สำหรับการโพลโดยไม่ต้องระงับการทำงาน

  • flush() รอจนกว่าการส่งที่ค้างอยู่จะถูกส่งไปยัง controller ครบถ้วน

ช่องสัญญาณ L2CAP มีลักษณะคล้าย stream ในแง่ที่ไบต์มาถึงตามลำดับและไม่สูญหาย แต่ขอบเขตของ send เดียวจะ ถูกรักษาไว้ กล่าวคือ SDU แต่ละหน่วยจะออกมาจาก recvinto เดียว ซึ่งต่างจาก TCP ที่ขอบเขตของ send() หนึ่งครั้งอาจกระจายข้ามการเรียก recv() หลายครั้ง

11.11.4. การจัดการการตัดการเชื่อมต่อ

ช่องสัญญาณจะสิ้นสุดในสามเงื่อนไข: ฝั่งใดฝั่งหนึ่งเรียก disconnect(), การเชื่อมต่อ GAP พื้นฐานขาด, หรือการตัดการเชื่อมต่อระดับ L2CAP มาถึง การดำเนินการที่กำลังทำอยู่จะ raise aioble.L2CAPDisconnectedError เช่นเดียวกับฝั่ง GATT สิ่งนี้จะปรากฏเป็น exception ใน coroutine ที่กำลังรอ และ block async with channel จะออกอย่างสะอาด

หากช่องสัญญาณไม่สามารถเข้าถึงได้เนื่องจากการตัดการเชื่อมต่อระดับ GAP แอปพลิเคชันจะวนกลับไปโฆษณาหรือสแกนในลักษณะเดียวกับการตัดการเชื่อมต่อ GATT

11.11.5. ต้นทุนหน่วยความจำ

MTU ขนาดใหญ่และคิวที่ยาวขึ้นใช้ RAM มากขึ้นทั้งสองฝั่ง MTU 512 ไบต์บวกกับ receive buffer ต่อช่องสัญญาณคือประมาณ 1 KB ต่อช่องสัญญาณ ซึ่งไม่ใช่ฟรีสำหรับ camera ขนาดเล็กหากมีช่องสัญญาณหลายช่องเปิดพร้อมกัน ควรใช้ช่องสัญญาณเดียวต่อการเชื่อมต่อและเลือก MTU ที่เหมาะกับขนาดข้อความที่คาดไว้ ค่าเริ่มต้นของ L2CAPChannel หนึ่งตัวต่อ DeviceConnection เพียงพอสำหรับแอปพลิเคชันส่วนใหญ่

L2CAP คือวาล์วนิรภัยของ BLE GATT คือสิ่งที่แอปพลิเคชันแทบทุกตัวเลือกใช้ก่อน และตัวอย่าง central/peripheral ที่เหลือในส่วนนี้ยังคงใช้ GATT API แบบช่องสัญญาณคือคำตอบเมื่อแอปพลิเคชันเติบโตเกินโมเดลคีย์/ค่า