DevelopOverview.rst 38.9 KB
Newer Older
Nelso Jost's avatar
Nelso Jost committed
1
2
3
4
5
6
###########
Visão geral
###########

Este documento procura oferecer uma visão geral sobre o funcionamento do código deste projeto, útil para aqueles que desejam modificá-lo ou simplesmente endendê-lo. 

7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
Funcionamento
*************

Este projeto compreende as duas seguintes ferramentas:

* **Firmware**
    
    Executado no processador do Arduino, é responsável por ler os sensores conectados de acordo com solicitações enviadas à porta serial. Utiliza bibliotecas de terceiros para leitura de sensores complexos.

* **Logger**
  
    Executado em uma máquina Linux (PC, Raspberry, etc), é responsável por coletar dados da placa através de uma leitura serial, fazer armazenamento local e também remoto através do envio de dados para o nosso servidor em `dados.cta.if.ufrgs.br/emm <http://dados.cta.if.ufrgs.br/emm>`_ ou algum outro especificado pelo usuário.

Ambos encontram-se no mesmo repositório pois o logger está preparado para enviar comandos pela serial cujo formato o firmware está preparado para receber. Por exemplo, considere a seguinte string enviada pelo logger à porta serial onde está a placa Arduino::

    readSensors,LDR,DHT22_TEMP

Ao receber esses caracteres, o firmware determinará que trata-se de um comando para leitura de sensores e que os sensores a serem lidos são, nessa ordem: o ``LDR`` e o ``DHT22_TEMP`` (luminosidade e temperatura, respectivamente). O firmware retorna pela serial uma resposta com números separados por vírgula, algo como::

    84,24.5

indicando 84 % de luminosidade e 24,5 ºC de temperatura. O logger estará então preparado para receber dois valores, guardá-los em um arquivo de log local (juntamente com a hora do sistema) e também fazer uma tentativa de envio ao servidor. Caso o envio falhe, a leitura será adicionada ao arquivo ``outgoing.json`` para futuras tentativas de comunicação com o servidor.

Opcionalmente, poderá ser utilizada a hora de um relógio ``RTC_DS1307`` da placa. Caso este não esteja presente ou não retorne valores consistentes, a hora do sistema é utilizada por padrão.

Exemplos
========

Segue abaixo o exemplo de um log de execução para uma estação 100% funcional, possuindo os 4 sensores oficialmente suportados (``DHT22_TEMP``, ``DHT22_AH``, ``BMP085_PRESSURE`` e ``LDR``) juntamente com o relógio ``RTC_DS1307``::

    2015-09-03 16:12:24 : INFO : ========================================
    2015-09-03 16:12:24 : INFO : Serial<id=0x7f1146fc5dd8, open=True>(port='/dev/ttyACM0', baudrate=9600, bytesize=8, parity='N', stopbits=1, timeout=1.5, xonxoff=True, rtscts=False, dsrdtr=False)
    2015-09-03 16:12:26 : INFO : sent: 'read,DHT22_TEMP,DHT22_AH,BMP085_PRESSURE,LDR,RTC_DS1307' (55 bytes)
    2015-09-03 16:12:29 : INFO : read: b'22.700001,66.199997,101224,40.762466,2015-9-3 16:12:26\r\n' (56 bytes)
    2015-09-03 16:12:29 : INFO : JSON: {'datetime': {'format': '%Y-%m-%d-%H-%M-%S', 'source': 'RTC_DS1307', 'value': '2015-09-03-16-12-26'}, 'sensors': {'LDR': '40.762466', 'DHT22_AH': '66.199997', 'BMP085_PRESSURE': '101224', 'DHT22_TEMP': '22.700001'}}
    2015-09-03 16:12:29 : INFO : Updated datalog file at '/home/nelso/lief/arduino-meteorolog/data/datalog-2015-09-03-15-37-00.csv'
    2015-09-03 16:12:29 : INFO : Starting new HTTP connection (1): localhost
    2015-09-03 16:12:29 : INFO : Server response: {'success': '1 new points were saved on the board.'}
    2015-09-03 16:12:29 : INFO : Going to sleep now for 0.2 minutes

A exemplo de como os erros são reportados, segue abaixo o log de execução para uma placa Arduino sem nenhum sensor, com um servidor fora do ar, mas com a mesma configuração ``settings.ini`` do exemplo anterior::

    2015-09-03 16:17:10 : INFO : ========================================
    2015-09-03 16:17:10 : INFO : Serial<id=0x7f2c89ffb438, open=True>(port='/dev/ttyACM0', baudrate=9600, bytesize=8, parity='N', stopbits=1, timeout=1.5, xonxoff=True, rtscts=False, dsrdtr=False)
    2015-09-03 16:17:12 : INFO : sent: 'read,DHT22_TEMP,DHT22_AH,BMP085_PRESSURE,LDR,RTC_DS1307' (55 bytes)
    2015-09-03 16:17:15 : INFO : read: b'<NaN>,0.000000,<bmp085_not_found>,50.537636,2165-165-165 165:165:85\r\n' (69 bytes)
    2015-09-03 16:17:15 : WARNING : SensorReadingError: [DHT22_TEMP]: '<NaN>'
    2015-09-03 16:17:15 : WARNING : SensorReadingError: [BMP085_PRESSURE]: '<bmp085_not_found>'
    2015-09-03 16:17:15 : WARNING : DateTimeError: [RTC_DS1307]: Expected format '%Y-%m-%d %H:%M:%S' but was given '2165-165-165 165:165:85' (Exception: time data '2165-165-165 165:165:85' does not match format '%Y-%m-%d %H:%M:%S')
    2015-09-03 16:17:15 : INFO : JSON: {'sensors': {'DHT22_AH': '0.000000', 'BMP085_PRESSURE': 'NaN', 'DHT22_TEMP': 'NaN', 'LDR': '50.537636'}, 'datetime': {'format': '%Y-%m-%d-%H-%M-%S', 'value': '2015-09-03-16-17-15', 'source': 'logger_system'}}
    2015-09-03 16:17:15 : INFO : Updated datalog file at '/home/nelso/lief/arduino-meteorolog/data/datalog-2015-09-03-16-17-10.csv'
    2015-09-03 16:17:15 : INFO : Starting new HTTP connection (1): localhost
    2015-09-03 16:17:15 : ERROR : Request: None. Unable to reach the server at 'http://localhost:5000/api/post/rawsensordata/2'. Exception: ('Connection aborted.', ConnectionRefusedError(111, 'Connection refused'))
    2015-09-03 16:17:15 : INFO : Updated local file '/home/nelso/lief/arduino-meteorolog/data/outgoing.json'.
    2015-09-03 16:17:15 : INFO : Going to sleep now for 0.2 minutes

Apenas sensores que utilizam o protocolo I2C podem ter sua presença detectada de antemão, como é o caso do ``BMP085`` e do ``RTC_DS1307``, retornando um erro como ``<bmp085_not_found>``. Repare que embora o **DHT22** não esteja presente na placa, o valor retornado pela leitura de umidade do ar foi ``0.000000``, claramente sem significado físico. O mesmo acontece com o **LDR**.

O log dispara **WARNINGS** para as falhas de leitura detectadas. No caso do relógio, o erro indica data inválida e portanto, a hora do sistema será utilizada. Por fim, o log também disparou um **ERROR** na tentativa de conexão com o servidor. A consequência é a criação do arquivo ``data/outgoing.json`` contendo dados a serem enviados em tentativas posteriores.

Nelso Jost's avatar
Nelso Jost committed
67
68
69
70
71
72
73
74
75
Estrutura de arquivos
*********************

Segue uma breve descrição dos arquivos/diretórios presentes na pasta raiz do projeto:

.. code-block:: shell

    arduino-meteorolog/
    ├── data/                # contém dados gerados pelo logger
76
    ├── docs/                # contém essa documentação 
Nelso Jost's avatar
Nelso Jost committed
77
78
79
80
    ├── logger/              # software que faz coleta de dados e envio para o servidor
    ├── meteorolog/          # projeto ".ino" do firmware (compilável pela Arduino Toolchain)
    ├── scripts/             # scripts utilizados pelo Makefile
    ├── settings.ini         # configurações do logger
81
82
83
84
    └── Makefile             # proporciona diversos comandos para facilitar a manutenção

Makefile
********
Nelso Jost's avatar
Nelso Jost committed
85

86
Esse arquivo contém diversos comandos simples a serem passados para a ferramenta ``make`` [1]_ de modo a facilitar o uso e manutenção dos softwares desse projeto. Basta estar na pasta onde se encontra o ``Makefile`` e executar:
Nelso Jost's avatar
Nelso Jost committed
87
88
89

.. code-block:: shell

90
    $ make <target>
Nelso Jost's avatar
Nelso Jost committed
91

92
para realizar alguma tarefa. Os *targets* possíveis são listados com ``make`` ou ``make help``::
Nelso Jost's avatar
Nelso Jost committed
93

94
95
96
97
     setup       Execute once to prepare the required Python virtual environment
     firmware    Compile and upload the firmware to the Arduino board via serial
     serial      Starts a serial session with Python for board communication
     sync-rtc    Synchronizes the board RTC_DS1307 with this system's time
Nelso Jost's avatar
Nelso Jost committed
98

99
100
101
     run         Execute the logger on the foreground. Hit Ctrl+C to stop it.
     deploy      Install logger on the Supervisor daemon tool (exec background)
     undeploy    Undo the 'deploy' command
Nelso Jost's avatar
Nelso Jost committed
102

103
104
105
     tail-log    Follow updates on the last execution log
     tail-data   Follow updates on the last data log
     plot-data col=x   Uses Gnuplot to plot last data log col number x
Nelso Jost's avatar
Nelso Jost committed
106

107
Na prática o usuário deverá fazer, ao obter uma cópia do repositório:
Nelso Jost's avatar
Nelso Jost committed
108

109
110
111
112
113
114
115
116
117
1. ``make setup`` para instalar as dependências do Logger em um ambiente virtual de Python
2. ``make firmware`` para compilar e gravar o firmware na placa Arduino. Alternativamente, isso pode ser feito pela IDE do Arduino.
3. ``make serial`` para testar a leitura dos sensores com ``>>> send('read,...')`` e também sincronizar o relógio da placa com o do sistema com ``>>> sync_rtc()``, caso possível.
4. ``make run`` para testar a execução do logger com a configuração atual de ``settings.ini``.
5. ``make deploy`` para instalar o logger no Supervisor (gerenciador de processos em background).
6. ``make tail-log`` para acompanhar o log da execução em background e certificar-se de que tudo ocorre como esperado.
 
Variáveis
=========
Nelso Jost's avatar
Nelso Jost committed
118

119
Na parte superior encontram-se definidas variáveis a serem utilizadas pela macro ``${VARIABLE_NAME}``.
Nelso Jost's avatar
Nelso Jost committed
120

121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
* ``PYBIN``

    Nome do executável de Python 3 a ser utilizado pelo comando ``make setup``. Padrão: ``python3``. Alguns sistemas utilizam outros nomes, como ``python-3.x`` (onde x é um número). Nesse caso, o usuário deverá passar o nome correto como em::

    $ make setup PYBIN=python-3.x   

* ``VENVDIR``

    Nome do diretório onde será instalado o ambiente virtual de Python pelo comando ``make setup``. Padrão: pasta ``.venv`` ao lado do ``Makefile``.

* ``VENVPY``

    Caminho do interpretador Python dentro do ambiente virtual. Mesmo que a versão instalada de Python seja 3.x, a ferramenta ``virtualenv`` disponibiliza o link simbólico ``python`` para acessar o interpretador, seja qual versão for. 

Sintaxe
=======

Cada *target* do ``Makefile`` contém uma série de comandos para o shell cuja funcionalidade é auto-explicativa. Vale apenas notar o detalhe de que um *target* pode ser executado por outro e, em caso de falha, nenhum outro comando ou *target* será executado.

A exemplo, considere a *target* ``run``::

    run: check-venv
        ${VENVPY} logger/run.py --verbose
Nelso Jost's avatar
Nelso Jost committed
144

145
146
147
Antes de executar seus comandos (no caso, apenas uma linha conforme identação), será executada a *target* ``check-venv``, que verifica a existência do ambiente virtual de Python e imprime uma menssagem de ajuda caso negativo.

.. note:: A sintaxe do ``Makefile`` impõe o uso de tabulação para comandos de um *target*. Editores configurados para expandir tabs em espaços (o que é recomendado para programação Python, por exemplo) deverão ser configurados para tratar arquivos ``Makefile`` de maneira separada, i.e., sem expandir tabs em espaços. Isto acontece por padrão no editor ``Vim``.
Nelso Jost's avatar
Nelso Jost committed
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199


########
Firmware
########

Escrito na linguagem C++ suportada pela Arduino Toolchain, pode ser compilado utilizando a IDE do Arduino ou pelo terminal através de ``$ make firmware``.

Estrutura de arquivos
*********************

.. code-block:: shell

    meteorolog/
    ├── libs/                         # bibliotecas de terceiros
    │   ├── Adafruit_BMP085.cpp       
    │   ├── Adafruit_BMP085.h         # sensor BMP085 (licença BSD)
    │   ├── DHT.cpp 
    │   ├── DHT.h                     # sensor DHT11 e 22 (licença MIT)
    │   ├── RTClib.cpp
    │   └── RTClib.h                  # relógio RTC DS1307 (domínio público)
    ├── meteorolog.ino                # setup() e loop() da Arduino Toolchain
    ├── mysensors.cpp
    ├── mysensors.h                   # leitura dos sensores disponíveis
    ├── boardcommands.cpp
    ├── boardcommands.h               # execução de comandos para a placa
    ├── utils.cpp 
    └── utils.h                       # utilidades suplentes da Arduino Toolchain

.. note:: Os arquivos ``.h`` (cabeçalhos) contém os protótipos juntamente com a documentação do código implementado nos ``.cpp``.

O ponto de entrada é o arquivo ``meteorolog.ino``, pois ele define as duas seguintes funções padrões de um Sketch Arduino:

* ``setup()``

    Executada uma vez quando a placa é ligada, inicializa a comunicação serial e chama ``mysensors_setup()``, que fará inicialização dos sensores.

* ``loop()``

    Executada enquanto a placa estiver ligada, verifica constantemente se há caracteres disponíveis na porta serial. Caso afirmativo, lê a string ali presente e encaminha ela para ``execute_board_command()``. Essa função, por sua vez, interpreta o comando presente na string recebida e retorna uma string como resposta, que é então devolvida para a porta serial e o loop recomeça.

boardcommands.h
***************

Os comandos esperados pelo firmware constituem strings no seguinte formato CSV::

    nomeDoComando,arg1,arg2,...,argN

Essa string contendo o comando e seus argumentos é enviada para ``execute_board_command()``, que interpretará a parte inicial ``nomeDoComando`` para delegar uma ação apropriada. O retorno de ``execute_board_command()`` é uma string contendo a resposta do comando ou, em caso de erros (comando inexistente, argumentos insuficientes, etc)::

    <invalid_commmand:nomeDoComando,...>

200
201
202
203
204
205
.. note:: Esses comandos devem ser enviados através de um monitor serial, como por exemplo o presente na IDE do Arduino. Alternativamente, esse projeto disponibiliza o *target* ``$ make serial`` para inicializar uma seção Python com uma comunicação aberta conforme configurado em ``settings.ini``. Nesse caso, os comandos da placa devem ser enviados como segue::

    >>> send('nomeDoComando,arg1,arg2,...,argN')

    onde ``send()`` é uma função definida no script ``init_serial.py`` que recebe uma string a ser enviada à porta serial e retorna uma string contendo a resposta lida pela porta.

Nelso Jost's avatar
Nelso Jost committed
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
readSensors
===========

A leitura dos sensores é feita pelo seguinte comando da placa::

    readSensors,nome1,nome2,...,nomeN

onde os argumentos ``nome1,nome2,...,nomeN`` são transmitidos para ``read_sensors()``, que fará as solicitações de leitura. Essa função itera sobre cada nome/apelido, passando o mesmo para ``call_read_sensor()`` de modo que a função correta de leitura seja invocada.

Por exemplo, sejam os dois seguintes sensores passados como argumento::

    LDR,p

O primeiro deve levar à execução da função ``read_LDR()`` e o segundo, à execução de ``read_BMP085_PRESSURE()`` (pois ``"p"`` é um apelido para ``BMP085_PRESSURE``). Ambas funções não recebem nenhum argumento e retornam uma string contendo, presumivelmente, o número medido ou um indicador de erro conforme programado em `my_sensors.h <https://git.cta.if.ufrgs.br/meteorolog/arduino-meteorolog/blob/master/meteorolog/mysensors.h>`_.

A operação de ``call_read_sensor()`` depende então de mapear-se uma string como ``"LDR"`` para um ponteiro da função ``read_LDR()``. Isso é alcançado em `boardcommands.cpp <https://git.cta.if.ufrgs.br/meteorolog/arduino-meteorolog/blob/master/meteorolog/boardcommands.cpp>`_ através dos três seguintes vetores globais:

* ``_sensor_names[]``: Contém o nome de todos os sensores disponíveis.
* ``_sensor_nicknames[]``: Contém todos os respectivos apelidos.
225
* ``_fp_read_sensor[]``: Contém os ponteiros de função das ``read_X()``, onde ``X`` é o nome de um sensor -- por exemplo, ``&read_LDR`` é o ponteiro de ``read_LDR()``.
Nelso Jost's avatar
Nelso Jost committed
226

227
Percorrendo-se os dois primeiros, ``call_read_sensor()`` busca por um nome/apelido válido. Caso encontre, o índice é utilizado para acessar ``_fp_read_sensor[]``, obter o ponteiro da função e finalmente executá-la.
Nelso Jost's avatar
Nelso Jost committed
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259

Os vetores são incializados com as respectivas constantes declaradas em `my_sensors.h <https://git.cta.if.ufrgs.br/meteorolog/arduino-meteorolog/blob/master/meteorolog/mysensors.h>`_. 

setRTC
======

A configuração do relógio (se presente) na placa é feita com o comando::

    setRTC,ano,mes,dia,hora,minuto,segundo

onde os argumentos ``ano,mes,dia,hora,minuto,segundo`` são repassados para ``set_time_from_csv()`` (`my_sensors.h <https://git.cta.if.ufrgs.br/meteorolog/arduino-meteorolog/blob/master/meteorolog/mysensors.h>`_), cujo funcionamento depende do RTC em questão (ver seção sobre o ``RTC_DS1307`` e suas funções).

Exemplo::

    setRTC,2015,8,17,14,43,10

caso bem sucedido deverá retornar a string::

    done: 2015-08-17 14:43:10

mysensors.h
***********

Esse módulo contém:

* Funções ``read_X()`` onde ``X`` é o nome de um sensor disponível;
* Função ``mysensors_setup()`` para inicialização programada de todos os sensores ao ligar a placa;
* Constantes a serem usadas por `boardcommands.cpp <https://git.cta.if.ufrgs.br/meteorolog/arduino-meteorolog/blob/master/meteorolog/boardcommands.cpp>`_ nos vetores de lookup das funções ``read_X()`` :

  * ``__SENSOR_COUNT``: total de sensores;
  * ``__SENSOR_NAMES``: vetor de strings de nomes de todos os sensores;
  * ``__SENSOR_NICKNAMES``: vetor de strings de apelidos de todos os sensores;
260
  * ``__FP_READ_SENSOR``: vetor de ponteiros de função das ``read_X()``.
Nelso Jost's avatar
Nelso Jost committed
261

262
.. note:: Entende-se aqui **sensor** por um elemento de software capaz de proporcionar um valor medido. Ou seja, ainda que um único componente eletrônico possa oferecer diversas medições (como temperatura e umidade do ar pelo DHT22), em termos do software cada medição é devida a um sensor, conforme cadastrado em http://dados.cta.if.ufrgs.br/emm. 
Nelso Jost's avatar
Nelso Jost committed
263

264
De um modo geral as funções ``read_X()`` são bem simples, pois apenas invocam funções externas para obteção da medição numérica que é então convertida para string -- o tipo de retorno esperado.
Nelso Jost's avatar
Nelso Jost committed
265

266
Por exemplo, sensores que atuam diretamente em pinos analógicos podem fazer uso de ``analogRead()`` (biblioteca do Arduino) e alguma matemática para calibração diretamente nas ``read_X()`` (a exemplo de ``read_LDR()``). Já sensores que possuem controladoras Wire ou I2C em gearl acabam fazendo uso de bibliotecas separadas para melhor organização do código. 
Nelso Jost's avatar
Nelso Jost committed
267

268
.. note:: Bibliotecas de terceiros são mantidas no subdiretório ``libs/``.
Nelso Jost's avatar
Nelso Jost committed
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297

Inserindo novos sensores
========================

O software do repositório contém o código básico para os sensores suportados oficialmente, mas nada impede que novos sensores sejam adicionados. Para isso, siga os seguintes passos:

**1. (opcional) Disponibilize uma biblioteca dentro de libs/**

Caso o código de leitura seja complexo demais, considere criar uma nova biblioteca::
    
    libs/novo_sensor.h
    libs/novo_sensor.cpp

Dica: utilize orientação a objetos para melhor organização.

**2. Registre o protótipo da nova função read_X() em mysensors.h**

.. code-block:: cpp

    #include "libs/novo_sensor.h"
    String read_NOVO_NOME();

onde ``NOVO_NOME`` será o nome do novo sensor. 

**3. Registre novo nome e apelido**

* Incremente ``__SENSOR_COUNT``;
* Inclua ``NOVO_NOME`` no vetor ``__SENSOR_NAMES``;
* Inclua um apelido curto qualquer no vetor ``__SENSOR_NICKNAMES``, na mesma posição utilizada por ``NOVO_NOME`` anteriormente;
298
* Inclua o ponteiro de função ``&read_NOVO_NOME`` no vetor ``__FP_READ_SENSOR``, na mesma posição utilizada por ``NOVO_NOME`` anteriormente.
Nelso Jost's avatar
Nelso Jost committed
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321

**4. Implemente o código em mysensors.cpp**

Exemplo :

.. code-block:: cpp

    // === NOVO_NOME SETUP =======================================

    #define NOVO_NOME_PIN        8   // digital

    String read_NOVO_NOME()
    {
        return FloatToString(...);
    } 

    // ===========================================================


######
Logger
######

322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
Software responsável por solicitar leitura dos sensores pela placa, guardar os dados localmente e também enviá-los ao servidor. Foi pensado para execução initerrupta em background através de um **daemon** registrado no gerenciador `supervisor <https://supervisor.readthedocs.org/en/latest/>`_.

Seguem abaixo algumas das filosofias do software:

* Configuração amigável ao usuário leigo;

* Sistema completo de logging (dados e execução);

* Sincronismo de dados locais e remotos;


Estrutura de arquivos
*********************

Este software está escrito na linguagem Python 3 e apresenta a seguinte estrutura de arquivos:

.. code-block:: shell

    logger/
    ├── app/                  # Python package contendo a aplicação
    │   ├── __init__.py       # torna essa pasta um package e faz inicializações
    │   ├── config.py         # gerencia configurações do programa
    │   └── main.py           # implementa a classe Meterologger
    ├── logs/                 # guarda logs de execução
    ├── deploy.py             # script para registrar o daemon do logger
    ├── init_serial.py        # script para obter uma conexão serial
    ├── requirements.pip      # bibliotecas Python de terceiros
    └── run.py                # ponto de entrada para excução do logger

.. note:: Normalmente em projetos Python, o arquivo de configuração fica presente no nível superior da pasta package ao lado de ``run.py``. No caso deste projeto, optamos por mantê-lo na raíz do repositório, na posição de destaque ao lado do ``Makefile``.

353
Projetos Python multi-arquivos fazem uso do conceito de **package**: pasta que contém um arquivo ``__init__.py`` para tornar-se acessível exteriormente como um módulo. Assim, o arquivo ``run.py`` que está fora do package pode fazer::
354
355
356

    from app.main import Meteorologger

357
Módulos internos do package podem acessar uns aos outros por importação relativa, como acontece em ``app/main.py``::
358
359
360

    from .config import Config

361
onde o operador ``.`` refere-se ao nível atual (``main.py`` e `]]]ncia]ncia]n`config.py`` estão na mesma pasta), ``..`` indica nível superior e assim por diante.
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383

Em resumo, o código do aplicativo logger está todo na pasta ``app/``, onde ``Meteorologger`` é a classe principal, e sua execução se dá pelo arquivo ``run.py`` com o seguinte ponto de entrada::

    Meteorologger().run()

.. note:: A instanciação ``Meteorologger()`` é responsável principalmente pelo carregamento do arquivo de configurações e sua validação. Já o método ``run()`` contém o loop infinito que consiste na execução do logger.

Dependências
************

Além da linguagem Python 3 o logger depende das seguintes bibliotecas de terceiros:

* ``pyserial``

    Possibilita comunicação entre Python e portas seriais. Aqui é utilizada para enviar comandos à placa Arduino e ler as respostas obtidas.

* ``requests``

    Alternativa à biblioteca padrão ``urllib``, usada para comunicação HTTP. Aqui é utilizada para enviar requests para a API do servidor em http://dados.cta.if.ufrgs.br/emm.

.. note:: As bibliotecas e suas versões estão listadas no arquivo  `requirements.pip <https://git.cta.if.ufrgs.br/meteorolog/arduino-meteorolog/blob/master/logger/requirements.pip>`_ para instalação automatizada através do gerenciador de pacotes **pip3** (vem por padrão com Python 3.4+).

384
Afim de evitar comprometer a instalação global do Python do usuário, optamos aqui pelo uso da ferramenta `virtualenv <https://virtualenv.pypa.io/en/latest/>`_. Todo o processo é automatizado pelo comando ``$ make setup``, cujo resultado é a criação de uma pasta ``.venv`` contendo uma instalação isolada de Python 3 e as bibliotecas mencionadas acima. A execução correta desse comando depende dos seguintes programas no sistema:
385
386
387

* **python3** : interpretador da linguagem Python 3.x (recomenda-se versão 3.4);

388
    * Instalação no Debian: ``$ sudo apt-get install python3``
389
390
391

* **pip3** : gerenciador de pacotes do Python 3;

392
    * Instalação no Debian: ``$ sudo apt-get install python3-pip``
393
394
395
396
397
398
399
400
401

* **virtualenv** : criação de ambientes virtuais de Python;

    * Instale via **pip3**: ``$ sudo pip3 install virtualenv``

Adicionamente, para que o logger possa ser executado em background (ver seção `deploy.py`) esse projeto também requer a seguinte ferramenta:

* **supervisor** : gerenciador de daemons (processos background);

402
    * Instalação no Debian: ``supervisor``
403

404
.. note:: Algumas distribuições podem possuir o executável de Python 3.x registrado em nomes diferentes de ``python3`` (assumido por ``$ make setup``). Nesse caso, forneça o nome correto fazendo, por exemplo::
405
406
407
    
    $ make setup PYBIN=python-3.x
 
408
409
410
 onde x é um número. O mesmo vale para o **pip3**::

    $ sudo pip-3.x install virtualenv
411
412
413
414

run.py
******

415
416
Este arquivo consiste no ponto de entrada da aplicação, permitindo a execução do logger por um interpretador Python: ``$ python3 run.py [options]``. Entretanto, conforme descrito na seção anterior sobre dependências, deve ser utilizado o interpretador do ambiente virtual através de:
 
417
418
.. code-block:: shell

419
    $ make run
420

421
.. note:: Deve ser executado após a criação do ambiente virtual com ``$ make setup``.
422
423
424
425

Parâmetros
==========

426
* ``--background``
427

428
    Desabilita impressão de menssagens de log na saída padrão.
429
430
431
432
433


deploy.py
*********

434
Conforme mencionado na introdução, o logger foi pensado como um programa para ser executado em background. Por exemplo, as menssagens do log de execução são escritas em um arquivo dentro de ``logger/logs`` através da biblioteca padrão `logging <https://docs.python.org/3/library/logging.html>`_. O script ``deploy.py`` é responsável por registrar um novo processo *daemon* no `Supervisor <https://supervisor.readthedocs.org/en/latest/>`_ para colocar o logger em execução no background, persistindo mesmo após a máquina ser reiniciada. 
435

436
A operação é feita pelo seguinte comando, que requer permissões de root:
437
438
439
440
441

.. code-block:: shell

    $ make deploy

442
O registro de um *daemon* no supervisor consiste na criação de um arquivo de configuração em ``/etc/supervisor/conf.d/`` e a subsquente execução de ``supervisorctl update``. É exatamente isso que faz a função ``deploy_supervisor()``. O arquivo de configuração utiliza o seguinte modelo presente na string ``TEMPLATE_SUPERVISOR_CONF``::
443
444
445
446
447
448
449
450
451
452
453

    [program:{PROCESS_NAME}]
    command={BASE_DIR}/.venv/bin/python {BASE_DIR}/logger/run.py
    directory={BASE_DIR}
    user=root
    autostart=true
    autorestart=true
    redirect_stderr=true
    stdout_logfile={BASE_DIR}/logger/logs/stdout.log
    logfile={BASE_DIR}/logger/logs/supervisor-{PROCESS_NAME}.log

454
Os valores substituídos nesse template estão declarados nas constantes globais, também utilizadas em outros lugares:
455

456
* ``PROCESS_NAME``: apelido para o *daemon* dentro do supervisor. Valor: ``meteorologger``.
457
458
* ``BASE_DIR``: diretório raiz do projeto, que contém o ``Makefile``. Obtido pelo cálculo relativo da posição do arquivo ``deploy.py``.

459
Sobre as configurações do **Supervisor**, vale destacar:
460
461
462
463

* ``redirect_stderr``: menssagens de erro serão escritas na saída padrão.
* ``stdout_logfile``: além das menssagens da saída padrão, o traceback aparecerá nesse arquivo caso o programa falhe.

464
Por fim, o mesmo script ``deploy.py`` é utilizado também para *undeployment*, isto é, remoção do *daemon* no Supervisor. Isso é feito passando-se o argumento ``-u`` para o script, operação disponibilizada pelo comando:
465
466
467
468
469
470
471
472

.. code-block:: shell

    $ make undeploy

app/config.py
*************

473
O arquivo de configuração utilizado pelo logger, `settings.ini <https://git.cta.if.ufrgs.br/meteorolog/arduino-meteorolog/blob/master/settings.ini>`_, encontra-se na pasta raiz do projeto ao lado do ``Makefile`` por ser uma posição de destaque. Foi concebido para ser configurado por um usuário leigo em computação.
474

475
Existem várias opções de sintaxe para arquivos de configuração no universo Python: *XML*, *JSON*, *YAML*, *INI*, etc. Apesar de que sintaticamente o *YAML* seja mais interessante para projetos Python por levar em conta a identação, esse mesmo motivo dificultaria a configuração por usuários leigos. A flexibilidade do formato *INI*, tratado pela biblioteca padrão ``configparser``, determinou sua escolha para esse projeto.
476

477
O módulo `app/config.py <https://git.cta.if.ufrgs.br/meteorolog/arduino-meteorolog/blob/master/app/config.py>`_ é responsável pela leitura e validação do arquivo de configuração através da classe ``Config``, que deverá se comportar como um dicionário para obteção das seções e chaves::
478

479
480
    config = Config()
    config['reading']['sleep_time']     # acessa a chave 'sleep_time' da seção 'reading'
481

482
Como todos valores lidos e armazenados pelo objeto ``configparser.ConfigParser`` são strings, optamos aqui por utilizar e manipular uma cópia em dicionário das configurações através do atributo ``_sections`` deste objeto. Assim, quando uma seção de configuração é acessada dentro de ``Config()`` (instancia) com o operador ``[]``, o método mágico ``__getitem__()`` retorna um dicionário dentro de ``_sections`` podendo conter qualquer tipo de dados como chaves e valores.
483

484
.. note:: As chaves do dicionário ``_sections`` são todas em *lowercase*, independente do original em ``settings.ini``! Esse fato é levado em conta na implementação da classe ``main.Meteorologger``.
485

486
No que diz respeito à validação dos dados, a classe ``Config`` implementa as três seguintes **exceptions** (classes que herdam de ``Exception``):
487

488
* ``ConfigMissingSectionError``
489

490
    Exemplo de menssagem::
491

492
493
494
         [reading]
          ^
         Missing section!
495

496
* ``ConfigMissingKeyError``
497

498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
    Exemplo de menssagem::

         [reading]
         ; time between logger cycles, in minutes
         SLEEP_TIME = 
                      ^
         Missing key!

* ``ConfigValueError``

    Exemplo de menssagem::

         [reading]
         ; time between logger cycles, in minutes
         SLEEP_TIME = 5-
                      ^
         TypeError: Number expected! 

Baseando-se na máxima pythônica de que "nenhum erro deve passar despercebido", ``ConfigMissingSectionError`` e ``ConfigMissingKeyError`` poderão acontecer no método ``assert_config_keys()`` responsável por assegurar a existência de seções e chaves em ``settings.ini`` tomando ``DEFAULT_INI`` como referência. Já ``ConfigValueError`` poderá acontecer ao longo dos métodos ``validate_section_()``, descritos na próxima seção.

Validações
==========

* ``validate_section_server()``
522

523
    Utiliza valores da seção ``[server]`` para compor a URL utilizada na postagem de dados. O valor ``URL`` consiste na base do endereço do servidor, opção disponibilizada para o caso de o usuário desejar utilizar outro servidor que não o nosso -- por exemplo, um servidor local como ``http://localhost``. O valor ``BOARD_ID`` é utilizado pela URL e também pela API do site, juntamente com ``USER_HASH``, ao realizar autenticação do usuário da placa.
524

525
* ``validate_section_reading()``
526

527
528
529
530
531
532
533
534
535
    Utiliza os valores da seção ``[reading]`` para determinar quais sensores terão a leitura solicitada pelo logger e também se deverá ser lido o relógio da placa (visto como um sensor de nome ``RTC_DS1307``). A ordem dos sensores na chave ``SENSORS`` determinará as colunas do arquivo *datalog.csv* (armazenamento local de dados).

    Introduz a nova chave ``reading/command`` contendo a linha de comando a ser enviada para a porta serial. Essa linha vai conter todos os sensores da chave ``SENSORS``, e também o ``RTC_DS1307`` caso a chave ``RTC_DS1304`` seja ``true``.

* ``validate_section_datalog()``

    Valida o caracetere utilizado como separador CSV do arquivo *datalog.csv*, configurado na chave ``CSV_SEP`` da seção ``[datalog]``. Além de eliminar opções inválidas, decodifi)ncia)ncica o caractere para uso ASCII correto posteriormente.  

* ``validate_section_arduino()``
536
537
538
539
540
541
542
543
544
545

    Valida a chave ``SERIAL_PORT`` da seção ``[arduino]``. O usuário pode especificar uma ou mais portas separadas por vírgula para que o logger tente conexão caso uma delas falhe. Adicionalmente, essa chave pode ser deixada em branco, caso em que será gerada a seguinte lista de portas:

    ``['/dev/ttyACM0', '/dev/ttyUSB0', ..., '/dev/tty/ACM4', '/dev/ttyUSB4']``

    para que o logger tente buscar sozinho a porta onde está a placa.

app/main.py
***********

546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
Este módulo contém toda a funcionalidade do logger em si implementada na classe ``Meteorologger``. Uma leitura do método ``Meteorologger.run()`` (ponto de entrada) dá uma idéia clara de cada etapa necessária ao fluxo de execução. 

Meteorologger.__init__()
========================

    A instanciação dessa classe inicializa o atributo ``background`` (flag utilizada pelo método ``setup_logging``) e também atributo ``config`` com uma instancia da classe ``Config``. Conforme discutido na seção anterior sobre ``app/config.py``, é nesse momento que ocorre a validação do arquivo de configuração.

Meteorologger.setup_session_files()
===================================

    Os seguintes arquivos serão criados a cada nova execução do logger (seja em *foreground* ou em *background*)::

        logger/logs/exec-%Y-%m-%d-%H-%M-%S.log
        data/datalog-%Y-%m-%d-%H-%M-%S.csv

    onde ``%Y-%m-%d-%H-%M-%S`` consiste no ``datetime`` do início da execução. Estabelecer o nome desses arquivos é o objetivo primário de ``setup_session_files()``. O primeiro arquivo é o log de execução e o segundo é o log de dados no formato **CSV** (*comma-separated values*), cuja primeira linha contendo o nome das colunas é escrita já na execução deste método para garantir existência e permissões de arquivo.

Meteorologger.setup_logging()
=============================

    Considerando que o logger foi pensado para execução em *background*, o uso de ``print()`` para menssagens de log não consiste na melhor abordagem -- por exemplo, deseja-se que a mesma menssagem apareça tanto em arquivo como na saída padrão. A excelente biblioteca padrão ``logging`` traz diversas soluções para esses e outros problemas relativos à criação de logs.

    O log em arquivo é criado conforme especificações de ``logging.basicConfig()``, seja a execução feita em *background* ou *foreground*. Neste último caso, desejamos imprimir também na tela as mesmas menssagens de log. Isto é alcançado adicionando-se o objeto ``logging.StreamHandler()`` ao logger principal ``root``. 

Meteorologger.get_serial()
==========================

    Esse método varre a lista de portas seriais ``self.config['arduino']['serial_port']`` em busca de uma conexão válida. Quando uma tentativa falha, registra-se um ``logging.error()`` prossegue-se com o próximo item da lista, retornando ao início quando o último item também falha.

.. note:: Vale lembrar que uma lista de portas é gerada automaticamente quando a chave ``arduino/SERIAL_PORT`` de ``settings.ini`` é deixada em branco. Nesse caso, deve-se assegurar de que a única placa Arduino presente na máquina é aquela na qual deseja-se conectar. 

Meteorologger.serial_read()
===========================

    A leitura dos dados consiste no envio de uma string para a porta serial e a consequente leitura da string de resposta. Logo, a primeira coisa a ser feita é obter uma conexão serial pelo método ``get_serial()``. Em seguida, entra-se em um loop que encerra apenas quando a resposta obtida é uma string ASCII válida.

    A comunicação serial ocorre através do objeto ``serial.Serial()`` (biblioteca `pyserial <http://pyserial.sourceforge.net/>`_) retornado pelo método ``get_serial()``. Tendo a conexão estabelecida, envia-se a string contendo o comando de leitura configurado em ``self.config['reading']['command']`` -- detalhe para o fato que strings em Python 3 são *unicode* por padrão e portanto devem ser convertidas para ``bytes()``.

    Uma boa prática consiste em dormir por um intervalo de tempo (``BOARD_RESPONSE_DELAY``, 3 segundos, por exemplo) para aguardar enquanto a placa é reiniciada pelo fato da conexão serial ter sido estabelecida via ``pyserial``. 
    
    A leitura da string de resposta retorna bytes que devem ser convertidos para string. No entanto, pode acontecer de bytes retornados não serem caracteres ASCII válidos (por exemplo, contém códigos de controle de envios interrompidos anteriormente). O método ``_decode_bytes()`` assegura essa validação.

Meteorologger.create_json()
===========================

    Esse método recebe uma string de valores CSV, por exemplo::

        <NaN>,80.0,101201,45.5,2015-09-01 18:30:12

    correspondendo aos sensores cuja leitura foi solicitada conforme ``self.config['reading']['sensors']``, por exemplo::
       
        DHT22TEMP,DHT22AH,BMP085_PRESSURE,LDR,RTC_DS1307
       
    e então retorna um dicionário *JSON* válido, por exemplo::

        {
            "datetime": 
            {
                "format": "%Y-%m-%d-%H-%M-%S",
                "source": "RTC_DS1307",
                "value": "2015-09-01-18-30-12"
            },
            "sensors":
            {
                "DHT22_TEMP": "NaN",
                "DHT22_AH": 80.0,
                "BMP085_PRESSURE": 101201,
                "LDR": 45.5
            }
        }

    O formato de serialização *JSON* é bastante usado na web, inclusive pela API do site `<http://dados.cta.if.ufrgs.br>`_. O dicionário acima contém tudo que o servidor precisa para armazenar os valores corretamente no banco de dados.

    No exemplo acima, a leitura de ``DHT22_TEMP`` retornou a string ``<NaN>``. É uma convenção deste projeto que todos os erros retornados pelo firmware apareçam entre ``<>`` para facilitar a identificação. O sensor ``BMP085_PRESSURE``, por exemplo, poderia ter retornado ``<BMP085_not_found>``. Independente do erro acusado pelo firmware, ``"NaN"`` será gravado como leitura tanto no datalog local como no servidor pois é um valor tratável pelas bibliotecas de plotagem.

Meteorologger.write_datalog()
=============================

    Não há segredo neste método: simplesmente adiciona uma nova linha CSV no arquivo de log de dados local com base no *JSON* recebido. Naturalmente o arquivo de dados não deve incluir notas de erro, de modo que apenas o valor ``NaN`` apacerá nas colunas onde algum erro de leitura tenha ocorrido.

    Caso deseje se informar sobre o erro o usuário pode fazer uma simples busca textual pelo *timestamp* no log de execução.

Meteorologger.send_to_server()
==============================

    Utiliza a excelente biblioteca ``requests`` para enviar os dados ao servidor, processo elaborado em diversas etapas para garantir o tratamento de possíveis erros:

1. Adiciona-se o *JSON* resultante da leitura atual no arquivo ``data/outgoing.json`` (será criado caso não exista). Cada linha desse arquivo conterá um *JSON* válido para o servidor.

2. Abre-se o arquivo ``data/outgoing.json`` para leitura e converte-se as linhas para uma lista de dicionários *JSON* válidos ao servidor. Essa lista é armazenada no atributo ``"data"`` do *JSON* principal.

3. Adiciona-se o atributo ``"user_hash"`` contendo a chave de autenticação do usuário da placa ao *JSON* principal.

4. É feita uma tentativa de envio do *JSON* principal. Caso bem suscedida, apaga-se o arquivo ``data/outgoing.json``. Caso falhe, seja por servidor fora do ar ou seja por uma resposta negativa do mesmo (resposta da API ser algo como ``{"error": ...}``), nada se faz ao arquivo ``data/outgoing.json``.

Naturalmente, enquanto a comunicação do servidor falhar, novas linhas são adicionadas ao arquivo ``data/outgoing.json`` e se acumularão com o tempo até que um envio único seja bem sucedido. Repare que tudo isso acontece de maneira independente ao log local.
Nelso Jost's avatar
Nelso Jost committed
642
643

.. [1] Requer que o programa ``make`` esteja instalado no sistema Linux. Felizmente ele vem por padrão nas principais distribuições.