Programando com PyUSB 1.0

Do tradutor :
Esta é uma tradução do manual Programming with PyUSB 1.0
Este guia foi escrito pelos desenvolvedores do PyUSB, mas rapidamente executando os commits, acredito que walac é o autor principal.

Deixe-me me apresentar


PyUSB 1.0 é uma biblioteca Python que fornece fácil acesso ao USB . O PyUSB fornece várias funções:

  • 100% escrito em Python:
    Diferentemente das versões 0.x que foram escritas em C, a versão 1.0 é escrita em Python. Isso permite que programadores Python sem experiência em C entendam melhor como o PyUSB funciona.
  • Neutralidade da plataforma:
    A versão 1.0 inclui um esquema de back-end de front-end. Ele isola a API dos detalhes de implementação específicos do sistema. A interface do IBackend conecta essas duas camadas. O PyUSB vem com backends internos para o libusb 0.1, libusb 1.0 e OpenUSB. Você pode escrever seu back-end, se quiser.
  • Portabilidade:
    O PyUSB deve ser executado em qualquer plataforma com Python> = 2.4, ctypes e pelo menos um dos back-ends internos suportados.
  • Simplicidade:
    A interação com um dispositivo USB nunca foi tão fácil! O USB é um protocolo complexo e o PyUSB possui boas predefinições para as configurações mais comuns.
  • Suporte de engrenagem isócrona:
    O PyUSB suporta transferências isócronas se o back-end subjacente as suportar.

Embora o PyUSB torne a programação USB menos dolorosa, este tutorial pressupõe que você tenha um conhecimento mínimo do protocolo USB. Se você não sabe nada sobre USB, recomendo o excelente livro USB Complete de Jan Axelson.

Chega de conversa, vamos escrever o código!


Quem é quem


Para começar, vamos dar uma descrição dos módulos PyUSB. Todos os módulos PyUSB estão sob usb , com os seguintes módulos:
MóduloDescrição do produto
núcleoO principal módulo USB.
utilFunções auxiliares.
controlarSolicitações de gerenciamento padrão.
legadoCamada de compatibilidade da versão 0.x.
back-endUm subpacote contendo back-end internos.

Por exemplo, para importar um módulo principal , digite o seguinte:

>>> import usb.core >>> dev = usb.core.find() 

Bem, vamos começar


A seguir, um programa simples que envia a string 'test' para a primeira fonte de dados encontrada (ponto de extremidade OUT):

  import usb.core import usb.util #    dev = usb.core.find(idVendor=0xfffe, idProduct=0x0001) #   ? if dev is None: raise ValueError('Device not found') #   .  ,   #    dev.set_configuration() #    cfg = dev.get_active_configuration() intf = cfg[(0,0)] ep = usb.util.find_descriptor( intf, #     custom_match = \ lambda e: \ usb.util.endpoint_direction(e.bEndpointAddress) == \ usb.util.ENDPOINT_OUT) assert ep is not None #   ep.write('test') 

As duas primeiras linhas importam os módulos do pacote PyUSB. usb.core é o módulo principal e usb.util contém funções auxiliares. O comando a seguir procura nosso dispositivo e retorna uma instância do objeto, se encontrar. Caso contrário, retorna Nenhum . Em seguida, definimos a configuração que usaremos. Nota: a ausência de argumentos significa que a configuração desejada foi definida por padrão. Como você verá, muitos recursos do PyUSB têm configurações padrão para os dispositivos mais comuns. Nesse caso, a primeira configuração encontrada é definida.

Em seguida, procuramos o ponto final no qual estamos interessados. Estamos procurando dentro da primeira interface que temos. Depois de encontrarmos esse ponto, enviamos dados para ele.

Se soubermos o endereço do terminal com antecedência, podemos simplesmente chamar a função de gravação do objeto do dispositivo:

 dev.write(1, 'test') 

Aqui, escrevemos a string 'test' no ponto de interrupção no endereço 1 . Todas essas funções serão discutidas melhor nas seções a seguir.

O que está errado?


Cada função no PyUSB lança uma exceção em caso de erro. Além das exceções padrão do Python , o PyUSB define usb.core.USBError para erros relacionados ao USB.

Você também pode usar as funções de log do PyUSB. Ele usa o módulo de log . Para usá-lo, defina a variável de ambiente PYUSB_DEBUG com um dos seguintes níveis de log: crítico , erro , aviso , informações ou depuração .

Por padrão, as mensagens são enviadas para sys.stderr . Se desejar, você pode redirecionar as mensagens de log para um arquivo, definindo a variável de ambiente PYUSB_LOG_FILENAME . Se o seu valor for o caminho correto para o arquivo, as mensagens serão gravadas nele, caso contrário, serão enviadas para sys.stderr .

Onde voce esta


A função find () no módulo principal é usada para localizar e numerar dispositivos conectados ao sistema. Por exemplo, digamos que nosso dispositivo tenha um ID de fornecedor com o valor 0xfffe e um ID de produto 0x0001. Se precisarmos encontrar este dispositivo, faremos o seguinte:

 import usb.core dev = usb.core.find(idVendor=0xfffe, idProduct=0x0001) if dev is None: raise ValueError('Our device is not connected') 

Isso é tudo, a função retornará o objeto usb.core.Device que representa nosso dispositivo. Se o dispositivo não for encontrado, ele retornará Nenhum . De fato, você pode usar qualquer campo da classe do Descritor de Dispositivos que desejar. Por exemplo, e se quisermos descobrir se há uma impressora USB conectada ao sistema? É muito fácil:

 #      ,   if usb.core.find(bDeviceClass=7) is None: raise ValueError('No printer found') 

7 é o código para a classe de impressora de acordo com a especificação USB. Ah, espere, e se eu quiser numerar todas as impressoras disponíveis? Não tem problema:

 #     ... printers = usb.core.find(find_all=True, bDeviceClass=7) # Python 2, Python 3,     import sys sys.stdout.write('There are ' + len(printers) + ' in the system\n.') 

O que aconteceu Bem, é hora de uma pequena explicação ... O find tem um parâmetro chamado find_all e o padrão é False. Quando é falso [1] , o find retornará o primeiro dispositivo que corresponder aos critérios especificados (falaremos sobre isso em breve). Se você passar um valor verdadeiro para o parâmetro, o find retornará uma lista de todos os dispositivos que correspondem aos critérios. Isso é tudo! Simples, certo?

Nós terminamos? Não! Ainda não contei tudo: muitos dispositivos colocam suas informações de classe no descritor de interface, em vez de no descritor de dispositivos. Portanto, para encontrar realmente todas as impressoras conectadas ao sistema, precisaremos passar por todas as configurações, assim como todas as interfaces, e verificar se uma delas está definida como bInterfaceClass 7. Se você é um programador como eu, pode se perguntar: existe alguma maneira mais fácil com a qual isso pode ser implementado. Resposta: sim, ele é. Para começar, vejamos o código pronto para encontrar todas as impressoras conectadas:

 import usb.core import usb.util import sys class find_class(object): def __init__(self, class_): self._class = class_ def __call__(self, device): #  ,   if device.bDeviceClass == self._class: return True # ,   ,   # ,     for cfg in device: # find_descriptor:  ? intf = usb.util.find_descriptor( cfg, bInterfaceClass=self._class ) if intf is not None: return True return False printers = usb.core.find(find_all=1, custom_match=find_class(7)) 

O parâmetro custom_match aceita qualquer objeto chamado que recebe o objeto do dispositivo. Ele deve retornar verdadeiro para um dispositivo adequado e falso para um inapropriado. Você também pode combinar custom_match com os campos do dispositivo, se desejar:

 #   ,    : printers = usb.core.find(find_all=1, custom_match=find_class(7), idVendor=0xfffe) 

Aqui estamos interessados ​​nas impressoras do fornecedor 0xfffe.

Descreva-se


Ok, encontramos nosso dispositivo, mas antes de interagir com ele, gostaríamos de saber mais sobre ele. Bem, você sabe, configurações, interfaces, terminais, tipos de fluxos de dados ...
Se você tiver um dispositivo, poderá acessar qualquer campo do descritor de dispositivo como propriedades do objeto:

 >>> dev.bLength >>> dev.bNumConfigurations >>> dev.bDeviceClass >>> # ... 

Para acessar as configurações disponíveis no dispositivo, você pode iterar o dispositivo:

 for cfg in dev: sys.stdout.write(str(cfg.bConfigurationValue) + '\n') 

Da mesma maneira, você pode iterar a configuração para acessar as interfaces, bem como iterar as interfaces para acessar seus pontos de controle. Cada tipo de objeto possui campos do descritor correspondente como atributos. Veja um exemplo:

 for cfg in dev: sys.stdout.write(str(cfg.bConfigurationValue) + '\n') for intf in cfg: sys.stdout.write('\t' + \ str(intf.bInterfaceNumber) + \ ',' + \ str(intf.bAlternateSetting) + \ '\n') for ep in intf: sys.stdout.write('\t\t' + \ str(ep.bEndpointAddress) + \ '\n') 

Você também pode usar índices para acesso aleatório aos descritores, como aqui:

 >>> #      >>> cfg = dev[1] >>> #      >>> intf = cfg[(0,0)] >>> #    >>> ep = intf[2] 

Como você pode ver, os índices são contados a partir de 0. Mas espere! Há algo de estranho na maneira como obtenho acesso à interface ... Sim, você está certo, o índice para Configuração usa uma série de dois valores, dos quais o primeiro é o índice da Interface e o segundo é uma configuração alternativa. Em geral, para acessar a primeira interface, mas com a segunda configuração, escreveremos cfg [(0,1)] .

Agora é a hora de aprender uma maneira poderosa de procurar descritores - uma função útil find_descriptor . Já vimos isso no exemplo de pesquisa da impressora. O find_descriptor funciona quase da mesma forma que o find , com duas exceções:

  • O find_descriptor recebe como primeiro parâmetro o descritor de origem que você pesquisará.
  • Não há parâmetro de back-end nele [2]

Por exemplo, se tivermos um descritor de configuração cfg e desejarmos encontrar todas as configurações alternativas para a interface 1, faremos isso:

 import usb.util alt = usb.util.find_descriptor(cfg, find_all=True, bInterfaceNumber=1) 

Observe que o find_descriptor está no módulo usb.util . Ele também aceita o parâmetro custom_match descrito anteriormente.

Lidamos com vários dispositivos idênticos

Às vezes, você pode ter dois dispositivos idênticos conectados ao computador. Como você pode distinguir entre eles? Os objetos de dispositivo vêm com dois atributos adicionais que não fazem parte da especificação USB, mas são muito úteis: os atributos de barramento e endereço . Antes de tudo, vale a pena dizer que esses atributos são provenientes do back-end, e o back-end pode não suportá-los - nesse caso, eles estão definidos como Nenhum . No entanto, esses atributos representam o número e o endereço do barramento do dispositivo e, como você deve ter adivinhado, podem ser usados ​​para distinguir entre dois dispositivos com os mesmos valores de atributo idVendor e idProduct .

Como devo trabalhar?


Após a conexão, os dispositivos USB devem ser configurados usando algumas consultas padrão. Quando comecei a estudar a especificação USB , fiquei desanimado com os descritores, configurações, interfaces, configurações alternativas, tipos de transferência e tudo mais ... E o pior de tudo, você não pode simplesmente ignorá-las: o dispositivo não funciona sem definir a configuração, mesmo que seja uma! O PyUSB está tentando tornar sua vida o mais simples possível. Por exemplo, depois de receber o objeto do seu dispositivo, antes de tudo, antes de interagir com ele, você precisa enviar uma solicitação de configuração_configuração . O parâmetro de configuração para esta consulta que lhe interessa é bConfigurationValue . A maioria dos dispositivos não tem mais de uma configuração, e o rastreamento do valor da configuração a ser usado é irritante (embora a maior parte do código que eu vi tenha codificado isso). Portanto, no PyUSB, você pode simplesmente enviar uma solicitação set_configuration sem argumentos. Nesse caso, ele instalará a primeira configuração encontrada (se o seu dispositivo tiver apenas uma, você não precisará se preocupar com o valor da configuração). Por exemplo, suponha que você tenha um dispositivo com um descritor de configuração e seu campo bConfigurationValue seja 5 [3] , as consultas subsequentes funcionarão da mesma maneira:

 >>> dev.set_configuration(5) #  >>> dev.set_configuration() #  ,   5 -  #  >>> cfg = util.find_descriptor(dev, bConfigurationValue=5) >>> cfg.set() #  >>> cfg = util.find_descriptor(dev, bConfigurationValue=5) >>> dev.set_configuration(cfg) 

Uau! Você pode usar o objeto Configuration como um parâmetro para set_configuration ! Sim, ele também possui um método definido para se configurar na configuração atual.

Outra opção que você precisa ou não precisa configurar é a opção de alterar interfaces. Cada dispositivo pode ter apenas uma configuração ativada por vez, e cada configuração pode ter mais de uma interface e você pode usar todas as interfaces ao mesmo tempo. Você entende melhor esse conceito se pensar na interface como um dispositivo lógico. Por exemplo, vamos imaginar uma impressora multifuncional, que ao mesmo tempo é uma impressora e um scanner. Para não complicar (ou pelo menos torná-lo o mais simples possível), vamos supor que ele tenha apenas uma configuração. Porque temos uma impressora e um scanner, a configuração possui 2 interfaces: uma para a impressora e outra para o scanner. Um dispositivo com mais de uma interface é chamado de dispositivo composto. Quando você conecta sua impressora multifuncional ao computador, o sistema operacional carrega dois drivers diferentes: um para cada dispositivo periférico “lógico” que você possui [4]

E as configurações de interface alternativas? Que bom que você perguntou. Uma interface possui uma ou mais configurações alternativas. Uma interface com apenas uma configuração alternativa é considerada sem configurações alternativas [5] Configurações alternativas para interfaces como uma configuração para dispositivos, ou seja, para cada interface, você pode ter apenas uma configuração alternativa ativa. Por exemplo, a especificação USB sugere que um dispositivo não pode ter um ponto de verificação isócrono em sua configuração alternativa primária [6] , para que o dispositivo de streaming tenha pelo menos duas configurações alternativas, com uma segunda configuração com um ponto de verificação isócrono. Porém, diferentemente das configurações, as interfaces com apenas uma configuração alternativa não precisam ser configuradas [7] Você seleciona uma configuração de interface alternativa usando a função set_interface_altsetting :

 >>> dev.set_interface_altsetting(interface = 0, alternate_setting = 0) 

Advertência

A especificação USB diz que o dispositivo pode retornar um erro se receber uma solicitação SET_INTERFACE para uma interface que não possui configurações alternativas adicionais. Portanto, se você não tiver certeza de que a interface possui mais de uma configuração alternativa ou se aceita a solicitação SET_INTERFACE, o método mais seguro é chamar set_interface_altsetting dentro do bloco try-except, como aqui:

 try: dev.set_interface_altsetting(...) except USBError: pass 

Você também pode usar o objeto Interface como um parâmetro de função, os parâmetros interface e alternate_setting são herdados automaticamente dos campos bInterfaceNumber e bAlternateSetting . Um exemplo:

 >>> intf = find_descriptor(...) >>> dev.set_interface_altsetting(intf) >>> intf.set_altsetting() # !        

Advertência

O objeto Interface deve pertencer ao descritor de configuração ativo.

Fale comigo querida


E agora é hora de entender como interagir com dispositivos USB. O USB possui quatro tipos de fluxos de dados: transferência em massa, transferência por interrupção, transferência isócrona e transferência de controle. Não pretendo explicar o objetivo de cada segmento e as diferenças entre eles. Portanto, presumo que você tenha pelo menos conhecimento básico de fluxos de dados USB.

O fluxo de dados de controle é o único fluxo cuja estrutura é descrita na especificação; o restante simplesmente envia e recebe dados brutos do ponto de vista USB. Portanto, você tem várias funções para trabalhar com fluxos de controle e o restante dos fluxos é processado pelas mesmas funções.

Você pode acessar o fluxo de dados de controle usando o método ctrl_transfer . É usado para fluxos de saída (OUT) e de entrada (IN). A direção do fluxo é determinada pelo parâmetro bmRequestType .

Os parâmetros ctrl_transfer quase coincidem com a estrutura da solicitação de controle. A seguir, é apresentado um exemplo de como organizar um fluxo de dados de controle. [8] :

 >>> msg = 'test' >>> assert dev.ctrl_transfer(0x40, CTRL_LOOPBACK_WRITE, 0, 0, msg) == len(msg) >>> ret = dev.ctrl_transfer(0xC0, CTRL_LOOPBACK_READ, 0, 0, len(msg)) >>> sret = ''.join([chr(x) for x in ret]) >>> assert sret == msg 

Este exemplo assume que nosso dispositivo inclui duas solicitações de controle de usuário que agem como um pipe de loopback. O que você escreve com a mensagem CTRL_LOOPBACK_WRITE , pode ler com a mensagem CTRL_LOOPBACK_READ .

Os quatro primeiros parâmetros - bmRequestType , bmRequest , wValue e wIndex - são os campos da estrutura padrão do fluxo de controle. O quinto parâmetro é os dados que estão sendo transferidos para o fluxo de dados de saída ou o número de dados que estão sendo lidos no fluxo de entrada. Os dados transmitidos podem ser qualquer tipo de sequência, que pode ser alimentada como parâmetro para a entrada do método __init__ para a matriz . Se não houver dados sendo transferidos, o parâmetro deverá ser definido como Nenhum (ou 0 no caso de um fluxo de dados de entrada). Há outro parâmetro opcional indicando o tempo limite da operação. Se você não passar, o tempo limite padrão será usado (mais sobre isso mais tarde). No fluxo de dados de saída, o valor retornado é o número de bytes realmente enviados ao dispositivo. No fluxo de entrada, o valor de retorno é uma matriz com os dados lidos.

Para outros fluxos, você pode usar os métodos de gravação e leitura , respectivamente, para gravar e ler dados. Você não precisa se preocupar com o tipo de fluxo - ele é detectado automaticamente pelo endereço do ponto de verificação. Aqui está nosso exemplo de loopback, desde que tenhamos um canal de loopback no ponto de interrupção 1:

 >>> msg = 'test' >>> assert len(dev.write(1, msg, 100)) == len(msg) >>> ret = dev.read(0x81, len(msg), 100) >>> sret = ''.join([chr(x) for x in ret]) >>> assert sret == msg 

O primeiro e o terceiro parâmetros são os mesmos para os dois métodos - este é o endereço do ponto de verificação e o tempo limite, respectivamente. O segundo parâmetro são os dados transmitidos (gravação) ou o número de bytes a serem lidos (lidos). Os dados retornados serão uma instância de um objeto de matriz para o método de leitura ou o número de bytes gravados para o método de gravação .

Nas versões beta 2, em vez do número de bytes, você pode passar para read ou ctrl_transfer um objeto de matriz no qual os dados serão lidos. Nesse caso, o número de bytes a serem lidos será o comprimento da matriz vezes o valor de array.itemsize .

No ctrl_transfer , o parâmetro timeout é opcional. Quando o tempo limite é omitido, a propriedade Device.default_timeout é usada como tempo limite operacional.

Controle-se


Além das funções de fluxo de dados, o módulo usb.control fornece funções que incluem solicitações de controle USB padrão e o módulo usb.util possui uma conveniente função get_string que exibe especificamente os descritores de linha.

Tópicos adicionais


Por trás de toda grande abstração há uma grande realização


Anteriormente, havia apenas libusb . Depois veio o libusb 1.0 e tivemos o libusb 0.1 e 1.0. Depois disso, criamos o OpenUSB e agora moramos na Torre de Babel da Biblioteca USB [9] Como o PyUSB lida com isso? Bem, PyUSB é uma biblioteca democrática, você pode escolher qual biblioteca deseja. De fato, você pode escrever sua própria biblioteca USB do zero e pedir ao PyUSB para usá-la.

A função find possui outro parâmetro, sobre o qual não falei. Este é o parâmetro de back - end . Se você não transferi-lo, um dos back-ends internos será usado. Um back-end é um objeto herdado de usb.backend.IBackend , responsável pela introdução de lixo USB específico do sistema operacional. Como você deve ter adivinhado, o libusb 0.1 embutido, o libusb 1.0 e o OpenUSB são back-end.

Você pode escrever seu próprio back-end e usá-lo. Apenas herde do IBackend e ative os métodos necessários. Pode ser necessário consultar a documentação do usb.backend para entender como isso é feito.

Não seja egoísta


Python tem o que chamamos de gerenciamento automático de memória . Isso significa que a máquina virtual decidirá quando descarregar objetos da memória. Sob o capô, o PyUSB gerencia todos os recursos de baixo nível com os quais você precisa trabalhar (aprovação da interface, ajuste do dispositivo etc.) e a maioria dos usuários não precisa se preocupar com isso. Porém, devido à natureza indefinida da destruição automática de objetos pelo Python, os usuários não podem prever quando os recursos alocados serão liberados. Alguns aplicativos precisam alocar e liberar recursos deterministicamente. Para tais aplicativos, o módulo usb.util fornece funções para interagir com o gerenciamento de recursos.

Se você desejar solicitar e liberar interfaces manualmente, poderá usar as funções claim_interface e release_interface .A função Claim_interface solicitará a interface especificada se o dispositivo ainda não o fez. Se o dispositivo já solicitou uma interface, ele não faz nada. Apenas release_interface vai lançar a interface especificada, se for solicitado. Se a interface não for solicitada, ela não fará nada. Você pode usar a consulta manual da interface para resolver o problema de seleção de configuração descrito na documentação do libusb . Se você quiser liberar todos os recursos alocados pelo objeto do dispositivo (incluindo as interfaces solicitadas), poderá usar a função dispose_resources

. Ele libera todos os recursos alocados e coloca o objeto do dispositivo (mas não no hardware do próprio dispositivo) no estado em que foi retornado após o uso da função find .

Definição manual da biblioteca


Em geral, um back-end é um invólucro de uma biblioteca compartilhada que implementa uma API para acessar o USB. Por padrão, o back-end usa a função ctypes find_library () . No Linux e outros sistemas operacionais do tipo Unix, o find_library tenta executar programas externos (como / sbin / ldconfig , gcc e objdump ) para localizar o arquivo da biblioteca.

Nos sistemas em que esses programas estão ausentes e / ou o cache da biblioteca está desativado, esta função não pode ser usada. Para superar as limitações, o PyUSB permite enviar a função personalizada find_library () para o back-end.

Um exemplo desse cenário seria:

 >>> import usb.core >>> import usb.backend.libusb1 >>> >>> backend = usb.backend.libusb1.get_backend(find_library=lambda x: "/usr/lib/libusb-1.0.so") >>> dev = usb.core.find(..., backend=backend) 

Observe que find_library é um argumento para a função get_backend () na qual você fornece a função responsável por encontrar a biblioteca certa para o back-end.

Regras da velha escola


Se você estiver escrevendo um aplicativo usando as antigas APIs do PyUSB (0. alguma coisa lá), pode estar se perguntando se precisa atualizar seu código para usar a nova API. Bem, você deve fazê-lo, mas não é necessário. O PyUSB 1.0 vem com o módulo de compatibilidade usb.legacy . Inclui a API antiga com base na nova API. “Bem, devo substituir minha linha de importação usb por import usb.legacy como usb para fazer meu aplicativo funcionar?”, Você pergunta. A resposta é sim, funcionará, mas não é necessário. Se você executar o aplicativo sem alterações, ele funcionará porque a linha de importação usb importa todos os símbolos públicos de usb.legacy. Se você encontrar um problema - provavelmente você encontrou um bug.

Me ajude por favor


Se precisar de ajuda, não me escreva um e-mail , há uma lista de e-mails para isso. As instruções de inscrição podem ser encontradas no site do PyUSB .

[1] Quando escrevo True ou False (com letra maiúscula), quero dizer os valores correspondentes da linguagem Python. E quando digo verdadeiro (verdadeiro) ou falso (falso), quero dizer qualquer expressão Python que seja considerada verdadeira ou falsa. (Essa semelhança ocorreu no original e ajuda a entender os conceitos de verdadeiro e falso na tradução. - Nota. ) :

[2] Consulte a documentação específica do back-end.

[3] A especificação USB não impõe nenhum valor específico ao valor da configuração. O mesmo vale para números de interface e configurações alternativas.

[4] Na verdade, tudo é um pouco mais complicado, mas isso é apenas uma explicação para nós.

[5] Eu sei que isso parece estranho.

[6] Isso ocorre porque, se não houver largura de banda para fluxos de dados isócronos durante a configuração do dispositivo, ele poderá ser numerado com sucesso.

[7] Isso não acontece na configuração porque o dispositivo pode estar em um estado não configurado.

[8] No PyUSB, os fluxos de dados de controle acessam o ponto de controle 0. Muito, muito, muito raramente, um dispositivo possui um ponto de controle de controle alternativo (nunca conheci esse dispositivo).

[9] Isso é apenas uma piada, não leve a sério. Ótimas opções são melhores que nenhuma escolha.

Source: https://habr.com/ru/post/pt430528/


All Articles