Arquitetura Contrato de API - ES2-UFPI/TermoLOG GitHub Wiki
Esta página define como o backend conversa com os outros dois lados do sistema, sempre por HTTPS com uma API REST no FastAPI:
- Gateway (nó central) ↔ backend: envio das leituras e retorno de configuração e comandos.
- Dashboard ↔ backend: tudo o que o painel web mostra e altera.
O contrato existe para que firmware, backend e frontend possam ser desenvolvidos em paralelo (#28). Qualquer mudança nele deve ser combinada com quem implementa os lados afetados.
- Todo payload é JSON em UTF-8.
- Datas e horas seguem a ISO 8601 com fuso, por exemplo
2026-10-16T14:30:00-03:00. O backend grava em UTC. O gateway precisa sincronizar o relógio por NTP antes de enviar leituras. - Temperatura em °C e umidade em % de umidade relativa, os dois como número decimal.
- IDs de sala, sensor, gateway, comando, alerta e usuário são inteiros gerados pelo backend.
- Os erros usam o formato padrão do FastAPI:
{ "detail": "mensagem" }. Nos erros de validação (422), odetailé a lista de campos inválidos.
O gateway fica atrás do NAT da filial, sem IP público, então o backend nunca chama o gateway. Toda conversa começa com o gateway enviando um lote, e tudo o que o backend precisa mandar de volta (confirmação, configuração nova e comandos) vai na resposta desse envio.
sequenceDiagram
participant N as Nó de borda
participant G as Gateway (nó central)
participant A as Backend
N->>G: leitura (LoRa)
G->>G: guarda na fila
G->>A: POST /gateways/{id}/leituras (lote)
A-->>G: 201 (ack + config nova + comandos)
G->>G: tira o lote da fila e guarda os comandos por sensor
N->>G: próxima leitura (LoRa)
G-->>N: ack + comando pendente, na janela de recepção do nó
N->>N: executa (ex. apita) e volta ao deep sleep
O gateway manda em todas as requisições a chave de API gerada no cadastro dele (POST /gateways, US27):
Authorization: Bearer <chave_api>
| Código | Quando | O que o gateway faz |
|---|---|---|
| 401 |
{ "detail": "credencial inválida" }. Chave ausente, inválida ou revogada. |
Não reenvia em loop: espera com backoff (ex. 1, 2, 4… até 5 min), mantém a fila e sinaliza o erro (LED ou log). |
| 403 |
{ "detail": "credencial não pertence a este gateway" }. A chave é válida, mas o {id} da URL é de outro gateway. |
Erro de configuração do firmware: registra no log e mantém a fila. |
O backend confere a chave e o {id} antes de ler o corpo da requisição.
Registra um lote de leituras que o gateway recebeu dos seus nós (#30).
- Req:
{
"lote_id": "3f0c6d1e-8a2b-4c55-9b7e-2d1f0a9c4e11",
"versao_config": 7,
"comandos_concluidos": [4],
"leituras": [
{ "sensor_id": 12, "temperatura": 4.3, "umidade": 61.0, "data_hora": "2026-10-16T14:30:00-03:00" },
{ "sensor_id": 13, "temperatura": 5.1, "umidade": 58.5, "data_hora": "2026-10-16T14:30:02-03:00" }
]
}| Campo | Tipo | Regra |
|---|---|---|
lote_id |
string (UUID) | Gerado pelo gateway, um por lote. Um lote reenviado mantém o mesmo lote_id. |
versao_config |
inteiro | Versão da configuração que o gateway já aplicou. Depois de ligar, manda 0. |
comandos_concluidos |
lista de inteiros | IDs dos comandos já entregues aos nós desde o último envio (1.3). Pode ser vazia. |
leituras |
lista | De 0 a 100 itens. Lista vazia é um heartbeat (1.2). |
sensor_id |
inteiro | Sensor cadastrado e ativo (#38). |
temperatura |
número | Entre -40 e 80 (limite físico do DHT22). |
umidade |
número | Entre 0 e 100. |
data_hora |
string (ISO 8601) | Hora da leitura no nó de borda. Não pode estar mais de 5 min no futuro. |
- 201:
{
"lote_id": "3f0c6d1e-8a2b-4c55-9b7e-2d1f0a9c4e11",
"aceitas": 2,
"rejeitadas": [],
"config": null,
"comandos": [
{ "id": 5, "sensor_id": 12, "tipo": "apitar", "duracao_s": 10, "expira_em": "2026-10-16T15:30:00-03:00" }
]
}| Campo | Significado |
|---|---|
aceitas |
Quantas leituras foram gravadas. |
rejeitadas[].indice |
Posição do item no array leituras (começa em 0). |
rejeitadas[].sensor_id |
Sensor do item recusado. |
rejeitadas[].motivo |
sensor_nao_cadastrado, sensor_inativo, valor_fora_do_limite ou data_hora_invalida. |
config |
Configuração completa dos sensores do gateway, só quando versao_config está desatualizada. Se não, null (1.4). |
comandos |
Comandos pendentes para os nós desse gateway. Lista vazia se não houver. |
-
422: lote malformado (JSON inválido, sem
lote_idou com mais de 100 leituras). O gateway descarta o lote e registra o erro no log, porque reenviar não resolve: o problema está no firmware. - 401 / 403: ver 1.1.
-
Fila: o gateway só tira o lote da fila quando recebe o 201 (#33). Sem resposta (internet fora, timeout de 30 s, erro 5xx), reenvia o mesmo lote com o mesmo
lote_idquando a conexão voltar. -
Idempotência: um
lote_idjá processado não é gravado de novo. O backend devolve o mesmo resultado da primeira vez. Isso cobre o caso em que o lote chegou, mas a resposta se perdeu no caminho. -
Validação por item: um item inválido não derruba o lote. Os válidos são gravados e os inválidos voltam em
rejeitadas. O gateway tira o lote inteiro da fila, inclusive as rejeitadas, porque reenviar não muda o resultado, e registra as rejeitadas no log. -
Heartbeat: se a fila ficar vazia por 60 s, o gateway envia um lote com
leituras: []. É assim que ele recebe configuração e comandos mesmo sem leituras novas, e é por esse contato que o backend sabe que o gateway está online (GET /gateways).
Um comando (por exemplo, apitar o buzzer de um nó) faz dois saltos, e nos dois quem recebe nunca é chamado diretamente:
-
Backend → gateway: o comando fica pendente no backend até o próximo envio do gateway e vai no campo
comandosda resposta. Atraso máximo: o intervalo de envio do gateway (60 s no pior caso, por causa do heartbeat). -
Gateway → nó: o nó passa a maior parte do tempo em deep sleep, com o rádio desligado. O gateway guarda o comando numa fila por
sensor_ide só o entrega quando aquele nó acorda e envia a próxima leitura. O nó deixa o rádio ouvindo por cerca de 1 s depois de cada envio, e o gateway responde nessa janela com o ack da leitura e o comando. Atraso máximo: o intervalo de leitura do nó.
Depois de entregar o comando ao nó, o gateway informa o id em comandos_concluidos no próximo lote. Até isso acontecer, o backend continua mandando o mesmo comando, e o gateway ignora um id que já tem na fila. Um comando que passa do expira_em sem ser entregue é descartado pelo gateway e pelo backend.
| Tipo | Campos | Efeito no nó |
|---|---|---|
apitar |
duracao_s |
Liga o buzzer pelo tempo indicado. |
O alarme por leitura fora da faixa não depende de comando: o nó compara cada leitura com a faixa que recebeu na configuração e apita sozinho (US21).
Sempre que um administrador muda a faixa ou a frequência de uma sala, ou cadastra ou desativa um sensor, a versão da configuração dos gateways afetados aumenta. Na próxima resposta a esses gateways, o campo config vem preenchido:
{
"versao": 8,
"sensores": [
{
"sensor_id": 12,
"intervalo_leitura_s": 300,
"faixa": { "temperatura_min": 2.0, "temperatura_max": 8.0, "umidade_min": 40.0, "umidade_max": 70.0 }
}
]
}- A configuração vem sempre completa, não só o que mudou. O gateway substitui a anterior e passa a mandar
versao_config: 8. - Depois de reiniciar, o gateway manda
versao_config: 0e recebe a configuração completa no primeiro envio. - O gateway repassa a cada nó, por LoRa e na mesma janela de recepção dos comandos, o intervalo de leitura (US03) e a faixa usada no alarme local (US21).
- Um sensor que sai da lista foi desativado. O gateway deixa de repassar configuração para ele.
{
"lote_id": "9b1e2c3d-0000-4a1b-8c2d-123456789abc",
"versao_config": 8,
"comandos_concluidos": [],
"leituras": [
{ "sensor_id": 12, "temperatura": 4.3, "umidade": 61.0, "data_hora": "2026-10-16T14:30:00-03:00" },
{ "sensor_id": 99, "temperatura": 4.0, "umidade": 60.0, "data_hora": "2026-10-16T14:30:00-03:00" },
{ "sensor_id": 13, "temperatura": 5.1, "umidade": 140.0, "data_hora": "2026-10-16T14:30:00-03:00" }
]
}Resposta 201:
{
"lote_id": "9b1e2c3d-0000-4a1b-8c2d-123456789abc",
"aceitas": 1,
"rejeitadas": [
{ "indice": 1, "sensor_id": 99, "motivo": "sensor_nao_cadastrado" },
{ "indice": 2, "sensor_id": 13, "motivo": "valor_fora_do_limite" }
],
"config": null,
"comandos": []
}- Depois do login, o dashboard manda o token em todas as requisições:
Authorization: Bearer <token>. - A sessão expira após 30 minutos sem requisições (US27). Depois disso qualquer rota responde 401 e o dashboard volta para a tela de login.
- Perfis (US24):
administradorevisualizador. As rotas marcadas como Admin respondem 403 para o visualizador, e a tentativa fica registrada.
Erros comuns a todas as rotas (não se repetem abaixo):
| Código | Quando |
|---|---|
| 401 |
{ "detail": "não autenticado" }. Sem token, token inválido ou sessão expirada. |
| 403 |
{ "detail": "permissão insuficiente" }. Perfil sem acesso à rota. |
| 404 |
{ "detail": "<recurso> não encontrado" }. ID do path não existe. |
| 422 | Erro de validação do FastAPI, com a lista de campos inválidos. |
Objeto faixa (usado em salas, status e configuração):
{ "temperatura_min": 2.0, "temperatura_max": 8.0, "umidade_min": 40.0, "umidade_max": 70.0 }O mínimo tem que ser menor que o máximo. Se não for, a resposta é 422.
Inicia a sessão com e-mail e senha (US27).
-
Req:
{ "email", "senha" } -
200:
{ "token", "expira_em", "usuario": { "id", "nome", "email", "perfil" } } -
401:
{ "detail": "e-mail ou senha inválidos" }. A mensagem é a mesma para e-mail inexistente e senha errada.
Encerra a sessão atual. O token deixa de valer.
- 204: sem corpo.
Retorna o usuário da sessão. O dashboard usa para decidir o que mostrar conforme o perfil.
-
200:
{ "id", "nome", "email", "perfil" }
Situação atual de todas as salas em uma chamada (#45). O dashboard chama essa rota a cada 30 s para atualizar os cards sem recarregar a página (#48).
- 200:
[
{
"sala_id": 1,
"nome": "Câmara fria 1",
"faixa": { "temperatura_min": 2.0, "temperatura_max": 8.0, "umidade_min": 40.0, "umidade_max": 70.0 },
"intervalo_leitura_s": 300,
"status": "fora_da_faixa",
"leituras": [
{ "sensor_id": 12, "temperatura": 9.1, "umidade": 61.0, "data_hora": "2026-10-16T14:30:00-03:00" }
]
}
]status pode ser ok, fora_da_faixa ou sem_leitura. leituras traz a última leitura de cada sensor ativo da sala. Uma sala sem leitura vem com a lista vazia.
Lista as salas.
-
200:
[{ "id", "nome", "descricao", "intervalo_leitura_s", "faixa" }]
Detalhe de uma sala com os sensores dela.
-
200:
{ "id", "nome", "descricao", "intervalo_leitura_s", "faixa", "sensores": [{ "id", "gateway_id", "ativo", "data_cadastro" }] }
Cadastra uma sala (#37).
-
Req:
{ "nome", "descricao"?, "intervalo_leitura_s", "faixa" } -
201: a sala criada, no mesmo formato de
GET /salas/{id}.
Altera nome, descrição, frequência de leitura ou faixa da sala (#41, US03). Só os campos enviados mudam.
-
Req: qualquer subconjunto de
{ "nome", "descricao", "intervalo_leitura_s", "faixa" } - 200: a sala atualizada.
- Se a faixa ou o intervalo mudar, a versão da configuração dos gateways da sala aumenta e eles recebem a nova no próximo envio (1.4).
Cadastra um sensor na sala. O ID é gerado pelo backend e nunca é reaproveitado (#36).
-
Req:
{ "gateway_id" } -
201:
{ "id", "sala_id", "sala_nome", "gateway_id", "ativo", "data_cadastro" }
Desativa o sensor. Não existe exclusão: o sensor desativado mantém o ID e o histórico (#36). Chamar de novo num sensor já inativo não muda nada.
-
200: o sensor com
"ativo": false.
Cria um comando para o nó do sensor, por exemplo apitar o buzzer para localizar o aparelho. O comando segue pelo gateway e chega ao nó no próximo ciclo de leitura dele (1.3).
-
Req:
{ "tipo": "apitar", "duracao_s": 10 } -
202:
{ "id", "sensor_id", "tipo", "duracao_s", "status": "pendente", "expira_em" }. O 202 indica que o comando foi aceito, mas ainda não chegou ao nó. -
409:
{ "detail": "sensor inativo" }
Histórico de leituras de um sensor, em ordem cronológica (#39).
-
Query:
inicioefim(ISO 8601, opcionais),pagina(padrão 1),tamanho(padrão 100, máximo 1000). -
200:
{ "sensor_id", "sala_nome", "total", "pagina", "itens": [{ "temperatura", "umidade", "data_hora" }] }
Lista os gateways. online é true quando o último envio ou heartbeat (1.2) foi há menos de 3 minutos.
-
200:
[{ "id", "nome", "online", "ultimo_contato" }]
Cadastra um gateway e gera a chave de API que ele usa para se autenticar (1.1).
-
Req:
{ "nome" } -
201:
{ "id", "nome", "chave_api" }. Achave_apiaparece só nesta resposta, porque o backend guarda apenas o hash dela. Ela tem que ser gravada no firmware do gateway.
Um alerta passa por pendente → reconhecido → encerrado. Depois de encerrado ele não muda mais (US25).
Lista os alertas, com os mais recentes primeiro. Pendentes e reconhecidos aparecem separados no painel filtrando por status (US12).
-
Query:
status(pendente,reconhecidoouencerrado, opcional),sala_id(opcional). - 200:
[
{
"id": 31,
"sala_id": 1,
"sala_nome": "Câmara fria 1",
"sensor_id": 12,
"tipo": "fora_da_faixa",
"status": "reconhecido",
"aberto_em": "2026-10-16T14:30:05-03:00",
"leitura": { "temperatura": 9.1, "umidade": 61.0, "data_hora": "2026-10-16T14:30:00-03:00" },
"reconhecido_por": { "id": 3, "nome": "Ana" },
"reconhecido_em": "2026-10-16T14:35:10-03:00",
"acao_corretiva": null,
"encerrado_em": null
}
]tipo pode ser fora_da_faixa ou sem_leitura. Num alerta sem_leitura, o campo leitura vem null.
Marca o alerta como visto (US12). Quem reconheceu e quando saem da sessão, nunca do corpo da requisição.
- 200: o alerta atualizado.
-
409:
{ "detail": "alerta não está pendente" }
Registra a ação corretiva e encerra o alerta (US26).
-
Req:
{ "acao_corretiva" }(obrigatório, não pode ser vazio) - 200: o alerta encerrado.
-
409:
{ "detail": "alerta precisa estar reconhecido" }ou{ "detail": "alerta já encerrado" }
Relatório de leituras de uma sala num período, para a vigilância sanitária (US10, US15).
-
Query:
sala_id,inicio,fim,formato(pdf,xlsxoucsv). -
200: o arquivo, com
Content-Typedo formato eContent-Disposition: attachment.
Relatório dos alertas e das ações corretivas de uma sala num período (US26).
-
Query e 200: iguais aos de
/relatorios/leituras.
- Nenhuma rota altera ou exclui leituras ou alertas encerrados (#54, US25). Isso é proposital.
- Cadastro de usuários, canais de notificação e plantão (US17, US20, US24) e calibração dos sensores (US18) entram aqui quando forem planejados.