11.9. Agir como periférico¶
O padrão BLE mais comum do lado da câmara é agir como periférico – publicar uma pequena base de dados GATT, anunciar a sua existência, aceitar uma ligação de um telemóvel ou dispositivo parceiro, e transmitir valores para quem estiver na outra extremidade.
11.9.1. Construir a base de dados GATT¶
A primeira coisa que um periférico faz ao arrancar – mesmo antes de ligar o rádio – é construir a base de dados que pretende expor, criar objetos para cada serviço e característica, e depois registar o conjunto:
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)
Cada aioble.Characteristic é associada ao seu serviço simplesmente por ser construída com o serviço como primeiro argumento. Os argumentos de palavra-chave booleanos (read, write, write_no_response, notify, indicate) selecionam quais as operações GATT que o cliente poderá realizar; passar False (o padrão) significa que o bit da propriedade não é definido.
aioble.register_services() confirma a árvore montada no servidor GATT. Deve ser chamada uma vez, antes de qualquer aioble.advertise() iniciar; chamá-la novamente substitui a base de dados anterior.
11.9.2. Publicidade¶
Uma vez a base de dados em vigor, a publicidade é uma única chamada de corrotina que aguarda uma ligação:
async def serve_one():
connection = await aioble.advertise(
interval_us=250000,
name="openmv-env",
services=[ENV_SERVICE],
appearance=0x0540, # Generic Sensor
)
Os argumentos de palavra-chave mapeiam diretamente para os campos do payload de publicidade. name é o campo de nome local; services é a lista de UUIDs de serviço que o dispositivo aloja (um scanner do lado do telemóvel pode filtrar por estes); appearance é uma sugestão dos valores de aparência de 16 bits standard que permite ao central apresentar um ícone adequado. Os dados específicos do fabricante são inseridos através de manufacturer=(company_id, data_bytes).
Um conjunto de palavras-chave menos comuns cobre o restante espaço de flags de publicidade:
connectable=False– modo apenas de difusão (nenhuma ligação é alguma vez aceite). A escolha certa para payloads estilo beacon.limited_disc=True– utiliza a flag discoverable limitado em vez de discoverable geral; alguns sistemas operativos tratam os dois de forma diferente na interface de emparelhamento.adv_data/resp_data– bytes em bruto se a aplicação precisar de controlo total sobre o layout.timeout_ms– desiste após um tempo fixo. O padrão é publicitar indefinidamente.
Quando um central se liga, aioble.advertise() devolve a aioble.DeviceConnection resultante. O periférico para de publicitar neste momento.
11.9.3. Servir um cliente¶
O ciclo principal de um periférico tem tipicamente este aspeto:
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 torna a limpeza após desligamento automática. disconnected() é uma corrotina que suspende até qualquer um dos lados terminar a ligação – uma forma limpa de manter o periférico em serviço até o central sair, para depois voltar a publicitar na ronda seguinte.
11.9.4. Atualizar uma característica¶
O periférico atualiza a base de dados GATT local com aioble.Characteristic.write()
temp_char.write(b"\\x9a\\x09") # 24.58 deg C as sint16, 0.01 units
Isso altera o valor que o próximo read de qualquer cliente devolveria. Por si só, não envia o novo valor – um cliente subscrito não verá nada até que o cliente faça uma sondagem ou o periférico envie uma notificação explícita.
O lado de envio é uma única palavra-chave na mesma chamada:
temp_char.write(temp_bytes, send_update=True)
send_update=True notifica (ou indica) todos os clientes que subscreveram esta característica. A maioria do código estilo sensor vive numa tarefa por ligação que faz um ciclo a ler o sensor e a escrever o valor com send_update=True a cada segundo ou assim:
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()
Se preferir direcionar uma notificação para um cliente específico em vez de todo o conjunto subscrito (por exemplo, uma resposta privada à ligação a um comando desse cliente), aioble.Characteristic.notify() e indicate() recebem um argumento DeviceConnection e um payload opcional.
11.9.5. Receber escritas¶
A outra direção – um cliente a escrever numa característica – fica disponível quando a característica é construída com write=True ou write_no_response=True. O periférico aguarda a próxima escrita com 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)
Sem capture=True, written() devolve apenas a ligação que escreveu; o novo valor encontra-se no buffer de suporte da característica e a aplicação obtém-no com read(). Se uma segunda escrita chegar antes de a aplicação ter lido a primeira, o segundo valor sobrescreve o primeiro no buffer e o valor original perde-se – written() ainda acorda a aplicação, mas apenas uma vez por «há algo novo», não uma vez por escrita.
A palavra-chave capture=True corrige isso. Cada escrita recebida é acrescentada a uma fila ao nível do módulo, e written() devolve um tuplo (connection, data) para cada escrita individual – o ciclo da aplicação vê cada uma exatamente uma vez, por ordem de chegada. Duas consequências práticas:
A fila é limitada e é partilhada entre todas as características com capture ativado no dispositivo. Curtas rajadas de escritas consecutivas são toleradas; sobrecarga sustentada (escritas a chegar mais depressa do que a aplicação as processa) descarta silenciosamente as entradas em fila mais antigas, e tráfego em rajada numa característica pode expulsar entradas pendentes de outra.
Escolha
capture=Truepara escritas estilo comando onde cada valor é importante. Deixe-o desativado para características estilo estado onde apenas o valor mais recente interessa.
Se uma leitura do cliente deve ser respondida por código a executar a pedido em vez de um valor estático, substitua on_read(). O método é chamado de forma síncrona quando chega uma leitura; devolva 0 para permitir a leitura (o valor atual de write() será enviado), ou um código de erro ATT diferente de zero para a rejeitar:
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)
O callback lê o sensor e atualiza o valor da característica mesmo antes de a pilha GATT servir a leitura, para que o cliente veja sempre dados frescos. O limite de taxa impede um cliente de sobrecarregar o sensor mais depressa do que ele pode ser amostrado – qualquer leitura dentro do período de cooldown de um segundo é devolvida como erro ATT Read Not Permitted em vez de um valor desatualizado.
11.9.5.1. Buffers de suporte maiores – BufferedCharacteristic¶
O buffer de suporte de uma Characteristic regular tem 20 bytes de largura – o limite prático no MTU padrão de 23 bytes. Um cliente que escreva mais do que isso numa característica regular tem o seu valor truncado. Para valores recebidos maiores ou para colocar em fila escritas consecutivas que o ciclo da aplicação alcançará mais tarde, declare a característica como BufferedCharacteristic e escolha o tamanho do buffer antecipadamente:
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)
Dois parâmetros distinguem-na de uma Characteristic simples:
max_lené o tamanho do buffer de suporte em bytes. Escolha-o para corresponder à maior escrita individual que o cliente deverá fazer (após a negociação do MTU).append=Truefaz com que escritas sequenciais acrescentem ao buffer em vez de sobrescrever – útil para receber um valor que chega em várias escritas (fragmentos de atualização de firmware, linhas de registo). Comappend=Falseo buffer comporta-se como uma característica normal, apenas mais largo.
Todas as outras flags do construtor (read, write, notify, indicate, capture, initial) são passadas sem alteração para a característica subjacente.
11.9.6. Serviços standard e os UUIDs atribuídos pelo SIG¶
Manter os UUIDs de números atribuídos (0x180F para Battery Service, 0x181A para Environmental Sensing, 0x180D para Heart Rate, etc.) significa que o menu genérico de Bluetooth de um telemóvel ou qualquer aplicação de scanner de terceiros pode identificar o propósito do dispositivo sem qualquer código de cliente personalizado. O layout de bytes dentro de cada característica standard também é fixado pela especificação – Battery Level (0x2A19) é um único byte 0..100; Temperature (0x2A6E) é sint16 little-endian em unidades de 0,01 grau Celsius. Para aplicações que não se enquadram num serviço standard, gere um UUID de 128 bits uma vez e utilize-o em todos os serviços e características do dispositivo.
Um periférico que publica apenas UUIDs personalizados é igualmente válido – apenas necessita de uma aplicação cliente personalizada que conheça esses UUIDs.
Nota
Os valores BLE são little-endian em todo o lado – a especificação GATT, todas as características standard, todos os campos de publicidade. Os inteiros de múltiplos bytes vão no fio com o byte menos significativo primeiro. O prefixo < nas strings de formato de struct é o que se pretende para codificar/descodificar ("<h", "<H", "<I", …); utilizar a ordem de bytes nativa padrão num MCU little-endian funciona por agora, mas escrever < explicitamente é o hábito seguro.
11.9.7. O rádio por detrás de tudo¶
O rádio está ativo no momento em que a primeira corrotina aioble o toca. Até que um central esteja ligado, o periférico passa o tempo a alternar entre breves rajadas de publicidade e modo de suspensão; após uma ligação, segue o intervalo de ligação negociado. O periférico paga um pequeno custo de energia por publicidade, pelo que a escolha de interval_us em aioble.advertise() é o parâmetro mais direto que um periférico tem para trocar latência de descoberta por duração da bateria.