11.9. Oheislaitteena toimiminen

Yleisin kameran puolen BLE-malli on toimia oheislaitteena – julkaista pieni GATT-tietokanta, mainostaa olemassaoloaan, hyväksyä yhteys puhelimelta tai parilaitteelta ja lähettää arvoja kenelle tahansa toisessa päässä olevalle.

11.9.1. GATT-tietokannan rakentaminen

Ensimmäinen asia, jonka oheislaite tekee käynnistyksessä – jo ennen radion käynnistämistä – on rakentaa tietokanta, jonka se aikoo julkaista, muodostaa oliot jokaiselle palvelulle ja ominaisuudelle ja sen jälkeen rekisteröidä koko joukko:

import aioble
import bluetooth

ENV_SERVICE = bluetooth.UUID(0x181A)              # Environmental Sensing
TEMP_UUID = bluetooth.UUID(0x2A6E)                # Temperature
HUMID_UUID = bluetooth.UUID(0x2A6F)               # Humidity

env = aioble.Service(ENV_SERVICE)
temp_char = aioble.Characteristic(
    env, TEMP_UUID,
    read=True, notify=True, initial=b"\\x00\\x00",
)
humid_char = aioble.Characteristic(
    env, HUMID_UUID,
    read=True, notify=True, initial=b"\\x00\\x00",
)

aioble.register_services(env)

Jokainen aioble.Characteristic liitetään palveluunsa yksinkertaisesti muodostamalla se siten, että palvelu annetaan ensimmäisenä argumenttina. Totuusarvoiset avainsana-argumentit (read, write, write_no_response, notify, indicate) valitsevat, mitkä GATT-operaatiot asiakkaan sallitaan suorittaa; arvon False (oletus) antaminen tarkoittaa, ettei kyseistä ominaisuusbittiä aseteta.

aioble.register_services() sitoo koostetun puun GATT-palvelimeen. Se on kutsuttava kerran, ennen kuin yksikään aioble.advertise() käynnistyy; sen kutsuminen uudelleen korvaa edellisen tietokannan.

11.9.2. Mainostaminen

Kun tietokanta on paikallaan, mainostaminen on yksi korutiinikutsu, joka odottaa yhteyttä:

async def serve_one():
    connection = await aioble.advertise(
        interval_us=250000,
        name="openmv-env",
        services=[ENV_SERVICE],
        appearance=0x0540,           # Generic Sensor
    )

Avainsana-argumentit kytkeytyvät suoraan mainostuksen hyötykuorman kenttiin. name on paikallisnimi-kenttä; services on lista palvelujen UUID-tunnisteista, joita laite isännöi (puhelimen puolen skanneri voi suodattaa näiden perusteella); appearance on vihje standardista 16-bittisistä ulkoasuarvoista, jonka avulla keskuslaite voi näyttää järkevän kuvakkeen. Valmistajakohtainen data annetaan kohdan manufacturer=(company_id, data_bytes) kautta.

Kourallinen harvinaisempia avainsanoja kattaa loput mainostuksen lippukentästä:

  • connectable=False – pelkkä lähetystila (yhteyttä ei koskaan hyväksytä). Oikea valinta majakkatyyppisille hyötykuormille.

  • limited_disc=True – käyttää rajoitetusti havaittava -lippua yleisesti havaittavan sijaan; jotkin käyttöjärjestelmät kohtelevat näitä kahta eri tavalla paritusliittymässään.

  • adv_data / resp_data – raakatavut, jos sovellus tarvitsee täyden hallinnan asettelusta.

  • timeout_ms – luovuta kiinteän ajan jälkeen. Oletus on mainostaa loputtomiin.

Kun keskuslaite muodostaa yhteyden, aioble.advertise() palauttaa syntyneen aioble.DeviceConnection-yhteyden. Oheislaite lopettaa mainostamisen tässä vaiheessa.

11.9.3. Yhden asiakkaan palveleminen

Oheislaitteen pääsilmukka näyttää tyypillisesti tältä:

async def serve():
    while True:
        connection = await aioble.advertise(
            interval_us=250000,
            name="openmv-env",
            services=[ENV_SERVICE],
        )
        print("connected:", connection.device.addr_hex())
        async with connection:
            await connection.disconnected()
        print("disconnected; advertising again")

asyncio.run(serve())

async with connection tekee yhteyden katkaisun siivouksesta automaattisen. disconnected() on korutiini, joka pysähtyy, kunnes jompikumpi osapuoli päättää yhteyden – siisti tapa pitää oheislaite palvelemassa, kunnes keskuslaite poistuu, ja palata sitten takaisin mainostamaan seuraavalla kierroksella.

11.9.4. Ominaisuuden päivittäminen

Oheislaite päivittää paikallista GATT-tietokantaa metodilla aioble.Characteristic.write()

temp_char.write(b"\\x9a\\x09")              # 24.58 deg C as sint16, 0.01 units

Tämä muuttaa arvoa, jonka seuraava read miltä tahansa asiakkaalta palauttaisi. Itsessään se ei työnnä uutta arvoa – tilannut asiakas ei näe mitään ennen kuin joko asiakas kysyy arvoa tai oheislaite lähettää nimenomaisen ilmoituksen.

Työntöpuoli on yksi avainsana samassa kutsussa:

temp_char.write(temp_bytes, send_update=True)

send_update=True ilmoittaa (tai osoittaa) jokaiselle asiakkaalle, joka on tilannut tämän ominaisuuden. Suurin osa sensorityyppisestä koodista elää yhteyskohtaisessa tehtävässä, joka silmukassa lukee sensoria ja kirjoittaa arvon send_update=True-asetuksella noin sekunnin välein:

async def stream_temperature(connection):
    while connection.is_connected():
        temp_char.write(encode_temperature(read_sensor()), send_update=True)
        await asyncio.sleep(1)

async def serve():
    while True:
        connection = await aioble.advertise(
            interval_us=250000,
            name="openmv-env",
            services=[ENV_SERVICE],
        )
        async with connection:
            asyncio.create_task(stream_temperature(connection))
            await connection.disconnected()

Jos haluat mieluummin kohdistaa ilmoituksen yhdelle tietylle asiakkaalle koko tilanneen joukon sijaan (vaikkapa yhteyskohtainen vastaus kyseisen asiakkaan komentoon), aioble.Characteristic.notify() ja indicate() ottavat DeviceConnection-argumentin ja valinnaisen hyötykuorman.

11.9.5. Kirjoitusten vastaanottaminen

Toinen suunta – asiakas kirjoittaa ominaisuuteen – tulee käytettäväksi, kun ominaisuus muodostetaan asetuksella write=True tai write_no_response=True. Oheislaite odottaa seuraavaa kirjoitusta metodilla aioble.Characteristic.written()

cmd_char = aioble.Characteristic(env, CMD_UUID, write=True, capture=True)

async def handle_commands():
    while True:
        connection, data = await cmd_char.written()
        print("command from", connection.device.addr_hex(), "=", data)

Ilman capture=True-asetusta written() palauttaa pelkän kirjoittavan yhteyden; uusi arvo elää ominaisuuden taustapuskurissa ja sovellus hakee sen metodilla read(). Jos toinen kirjoitus saapuu ennen kuin sovellus on lukenut ensimmäisen, toinen arvo korvaa ensimmäisen puskurissa ja alkuperäinen arvo menetetään – written() herättää silti sovelluksen, mutta vain kerran per ”jotain uutta on”, ei kerran per kirjoitus.

Avainsana capture=True korjaa tämän. Jokainen saapuva kirjoitus lisätään moduulinlaajuiseen jonoon, ja written() palauttaa (connection, data) -monikon jokaisesta yksittäisestä kirjoituksesta – sovellussilmukka näkee jokaisen täsmälleen kerran saapumisjärjestyksessä. Kaksi käytännön seurausta:

  • Jono on rajallinen ja jaettu kaikkien laitteen capture-toiminnon mahdollistavien ominaisuuksien kesken. Lyhyitä peräkkäisten kirjoitusten purskeita siedetään; jatkuva ylivuoto (kirjoituksia saapuu nopeammin kuin sovellus tyhjentää niitä) pudottaa hiljaisesti vanhimmat jonossa olevat merkinnät, ja yhden ominaisuuden purskeinen liikenne voi karkottaa toisen odottavia merkintöjä.

  • Valitse capture=True komentotyyppisille kirjoituksille, joissa jokaisella arvolla on merkitystä. Jätä se pois tilatyyppisille ominaisuuksille, joissa vain viimeisin arvo kiinnostaa.

Jos asiakkaan luku tulisi vastata pyynnöstä ajettavalla koodilla staattisen arvon sijaan, ohita on_read(). Metodia kutsutaan synkronisesti, kun luku saapuu; palauta 0 salliaksesi luvun (lähetetään nykyinen arvo metodilta write()), tai nollasta poikkeava ATT-virhekoodi hylätäksesi sen:

import time

_ATT_ERR_READ_NOT_PERMITTED = const(0x02)
_MIN_READ_INTERVAL_MS = const(1000)            # at most once per second

class TempChar(aioble.Characteristic):
    _last_read_ms = 0

    def on_read(self, connection):
        now = time.ticks_ms()
        if time.ticks_diff(now, self._last_read_ms) < _MIN_READ_INTERVAL_MS:
            return _ATT_ERR_READ_NOT_PERMITTED
        self._last_read_ms = now
        self.write(encode_temperature(read_sensor()))
        return 0

temp_char = TempChar(env, TEMP_UUID, read=True)

Takaisinkutsu näytteistää sensorin ja päivittää ominaisuuden arvon juuri ennen kuin GATT-pino palvelee luvun, joten asiakas näkee aina tuoretta dataa. Nopeusrajoitus estää asiakasta hakkaamasta sensoria nopeammin kuin se voidaan näytteistää – mikä tahansa luku yhden sekunnin jäähdytysajan sisällä torjutaan Read Not Permitted -ATT-virheellä vanhentuneen arvon sijaan.

11.9.5.1. Suuremmat taustapuskurit – BufferedCharacteristic

Tavallisen Characteristic-ominaisuuden taustapuskuri on 20 tavua leveä – käytännön raja oletusarvoisella 23 tavun MTU:lla. Asiakas, joka kirjoittaa tätä enemmän tavalliseen ominaisuuteen, saa arvonsa katkaistuksi. Suurempia saapuvia arvoja varten tai peräkkäisten kirjoitusten jonottamiseksi, jotka sovellussilmukka kuroo myöhemmin kiinni, määrittele ominaisuus tyypiltään BufferedCharacteristic ja valitse puskurin koko etukäteen:

blob = aioble.BufferedCharacteristic(
    service, BLOB_UUID,
    max_len=512, append=True,
    write=True, capture=True,
)

async def receive_blob():
    while True:
        connection, chunk = await blob.written()
        handle_chunk(connection, chunk)

Kaksi säädintä erottaa sen tavallisesta Characteristic-ominaisuudesta:

  • max_len on taustapuskurin koko tavuina. Valitse se vastaamaan suurinta yksittäistä kirjoitusta, jonka asiakkaan odotetaan tekevän (MTU-neuvottelun jälkeen).

  • append=True saa peräkkäiset kirjoitukset lisäytymään puskuriin korvaamisen sijaan – hyödyllistä vastaanotettaessa arvoa, joka saapuu useassa kirjoituksessa (laiteohjelmiston päivityspalat, lokirivit). Asetuksella append=False puskuri käyttäytyy kuin tavallinen ominaisuus, vain leveämpänä.

Kaikki muut muodostimen liput (read, write, notify, indicate, capture, initial) välitetään muuttumattomina taustalla olevalle ominaisuudelle.

11.9.6. Standardipalvelut ja SIG:n määräämät UUID-tunnisteet

Pitäytyminen määrätyissä UUID-numeroissa (0x180F Battery Service -palvelulle, 0x181A Environmental Sensing -palvelulle, 0x180D Heart Rate -palvelulle ja niin edelleen) tarkoittaa, että puhelimen yleinen Bluetooth-valikko tai mikä tahansa kolmannen osapuolen skanneri-sovellus voi tunnistaa laitteen tarkoituksen ilman mukautettua asiakaskoodia. Myös tavuasettelu kunkin standardiominaisuuden sisällä on speksin määräämä – Battery Level (0x2A19) on yksi tavu 0..100; Temperature (0x2A6E) on little-endian sint16 yksiköissä 0.01 deg-C. Sovelluksille, jotka eivät sovi standardipalveluun, generoi 128-bittinen UUID kerran ja käytä sitä laitteen palvelujen ja ominaisuuksien läpi.

Oheislaite, joka julkaisee vain mukautettuja UUID-tunnisteita, on silti kunnossa – se tarvitsee vain mukautetun asiakassovelluksen, joka tuntee nämä UUID-tunnisteet.

Muista

BLE-arvot ovat kaikkialla little-endian – GATT-speksi, jokainen standardiominaisuus, jokainen mainostuskenttä. Monitavuiset kokonaisluvut menevät johdolle vähiten merkitsevä tavu ensin. <-etuliite struct-muotoilumerkkijonoissa on se, mitä haluat koodaukseen/dekoodaukseen ("<h", "<H", "<I", …); little-endian-MCU:lla oletusarvoisen natiivin tavujärjestyksen käyttö sattuu toimimaan toistaiseksi, mutta <-merkin kirjoittaminen auki on turvallinen tapa.

11.9.7. Radio kaiken takana

Radio on päällä siitä hetkestä, kun ensimmäinen aioble-korutiini koskettaa sitä. Kunnes keskuslaite on yhdistetty, oheislaite viettää aikansa vaihdellen lyhyiden mainostuspurskeiden ja unen välillä; yhteyden jälkeen se noudattaa neuvoteltua yhteysväliä. Oheislaite maksaa pienen virtakustannuksen jokaista mainosta kohti, joten interval_us-arvon valinta funktiossa aioble.advertise() on suorin säädin, joka oheislaitteella on löytymisviiveen ja akun keston tasapainottamiseen.