CI Python Django PostgreSQL Docker OpenTelemetry License: MIT
LabTelemetryLaboratório de telemetria OT/IT reproduzível: ingestão industrial (Modbus TCP, OPC-UA, simulador determinístico), regras de qualidade de processo, API JSON e dashboard operacional.
- Idempotência garantida pelo banco, não pela aplicação. A deduplicação vem de
UniqueConstraint(sensor, timestamp)combinada combulk_create(ignore_conflicts=True)— uma checagem por lote dentro do Postgres, em vez de umSELECTpor amostra vindo do Python. O guardrail tem teste negativo: sem o mecanismo, o replay estouraIntegrityError. - Fontes OT plugáveis atrás de uma única ABC.
TelemetrySourcedefineread()/health()/close(); Modbus TCP, OPC-UA e simulador são intercambiáveis via--source. Adicionar um protocolo não toca o código de ingestão. - Mapeamento tag→ponto é configuração explícita, não convenção. Node OPC-UA e registrador Modbus são ligados ao sensor por
--opcua-node "ns=2;i=101:3"e--modbus-register "0:3:0.01". Índice posicional não é chave primária de sensor — tratá-lo como tal produz dado plausível e errado, então o comando exige o mapeamento em vez de adivinhar. - Fator de escala como cidadão de primeira classe. Holding register é uint16: um pH de 7.40 não cabe nele. O CLP publica
740e o mapeamento diz como voltar à grandeza física. Sem isso a leitura entra como pH 740 — fora de faixa, e errada de um jeito que só aparece no gráfico. - Simulador determinístico por seed. Reproduzir uma sequência de falha é
--seed 42, não "esperar o sensor falhar de novo" — o que torna o teste das regras de qualidade repetível. - Observabilidade opcional em runtime. OpenTelemetry é ligado por
OTEL_ENABLED; desligado, o custo é zero e nenhuma dependência de trace entra no caminho da request. - Qualidade de processo separada da persistência.
evaluate_reading()é pura (sem I/O), o que permite avaliar o lote inteiro em memória antes de um único INSERT. - Degradação explícita, não silenciosa. Se
pymodbusnão está instalado ou o CLP está fora do ar,/api/health/sources/reportadisconnected— a fonte não some do inventário.
| Camada | Tecnologia |
|---|---|
| Aquisição OT | Modbus TCP (pymodbus), OPC-UA (asyncua), simulador determinístico |
| Ingestão | Django management command (ingest_telemetry), lote com bulk_create |
| Qualidade | Regras de limite de processo e detecção de drift (telemetry/quality.py) |
| Persistência | PostgreSQL 16 (Docker Compose); SQLite como fallback local |
| Backend | Django 5.2 / Python 3.12 |
| API | Endpoints JSON server-side, sem framework REST adicional |
| Frontend | Django Templates + HTMX + Chart.js |
| Observabilidade | OpenTelemetry → Jaeger 2 (opt-in via OTEL_ENABLED) |
| Runtime | Docker + Gunicorn |
| Qualidade de código | ruff, 85 testes Django, gate de cobertura em 85%, CI no GitHub Actions |
| Reprodutibilidade | Dependências fixadas exatamente, diretas e transitivas |
flowchart LR
subgraph OT["Camada OT"]
MB["Modbus TCP<br/>(CLP / RTU)"]
UA["OPC-UA<br/>(servidor)"]
SIM["Simulador<br/>(seed determinístico)"]
end
subgraph ING["Ingestão"]
ADP["TelemetrySource (ABC)<br/>read / health / close"]
QA["evaluate_reading()<br/>limites + drift"]
BULK["bulk_create<br/>ignore_conflicts"]
end
subgraph IT["Camada IT"]
DB[("PostgreSQL 16<br/>UniqueConstraint<br/>sensor + timestamp")]
API["API JSON<br/>/api/..."]
DASH["Dashboard<br/>HTMX + Chart.js"]
ALERT["TelemetryAlert<br/>raise_alert idempotente"]
end
OTEL(["OpenTelemetry → Jaeger<br/>opt-in"])
MB --> ADP
UA --> ADP
SIM --> ADP
ADP --> QA --> BULK --> DB
QA --> ALERT --> DB
DB --> API --> DASH
API -.-> OTEL
Renderizado pelo Django, atualizado por HTMX em fragmentos parciais (cards, leituras, alertas, sensores, saúde das fontes) — sem SPA e sem build step de frontend.
| Endpoint | Retorno |
|---|---|
GET /api/summary/ |
Contagens agregadas e timestamp da última leitura |
GET /api/sensors/ |
Inventário de sensores com fator de calibração |
GET /api/readings/recent/ |
Últimas leituras (?limit=, teto de 500) |
GET /api/sensors/<id>/readings/ |
Série temporal de um sensor |
GET /api/alerts/active/ |
Alertas operacionais ativos |
GET /api/health/sources/ |
Estado de conexão de cada fonte OT |
Contrato completo em docs/data-contract.md.
Pré-requisitos: Python 3.12+, Docker Compose.
git clone https://github.com/Roberton003/labtelemetry.git
cd labtelemetry
cp .env.example .env
docker compose up --build -dDashboard em http://127.0.0.1:8000/ · Jaeger em http://localhost:16686
Já tem um Postgres ou um collector OTLP local ocupando as portas? Sobrescreva sem editar o compose:
POSTGRES_PORT=55432 OTLP_GRPC_PORT=54317 OTLP_HTTP_PORT=54318 docker compose up -d
python3 -m venv .venv && source .venv/bin/activate pip install -r requirements.txt cp .env.example .env python labtelemetry/manage.py migrate python labtelemetry/manage.py runserver 127.0.0.1:8000
# Uma leitura de cada sensor, a partir do simulador determinístico python labtelemetry/manage.py ingest_telemetry --source simulator --once --sim-count 3 # Loop contínuo a cada 5s (Ctrl+C encerra de forma limpa) python labtelemetry/manage.py ingest_telemetry --source simulator --interval 5 # Fonte industrial real — Modbus TCP # registrador 0 -> sensor 1, com escala: o CLP publica 740, o pH é 7.40 python labtelemetry/manage.py ingest_telemetry --source modbus \ --modbus-host 192.168.0.10 \ --modbus-register "0:1:0.01" \ --modbus-register "4:2:0.1" # Fonte industrial real — OPC-UA (cada node mapeado ao sensor que alimenta) python labtelemetry/manage.py ingest_telemetry --source opcua \ --opcua-url opc.tcp://plc.local:4840 \ --opcua-node "ns=2;i=101:1" \ --opcua-node "ns=2;i=103:5" curl -s http://127.0.0.1:8000/api/summary/
pip install -r requirements-dev.txt python labtelemetry/manage.py test telemetry # 85 testes ruff check labtelemetry/ # Gate de cobertura (mínimo 85%, atual 89%) — rodar da raiz do repo coverage run labtelemetry/manage.py test telemetry --exclude-tag=integration coverage report
Os testes marcados @tag("integration") sobem um servidor OPC-UA real. Rodam no gate de correção, mas ficam fora da medição de cobertura: sob o tracer do coverage, o startup do servidor passa de ~2s para ~56s.
| Job | Cobre |
|---|---|
lint |
ruff, em versão fixa |
test |
85 testes contra PostgreSQL, migrations em sincronia com os models, gate de cobertura |
e2e |
Sobe o docker compose de verdade: Dockerfile, entrypoint, migrate no boot, API com dados e traces chegando ao Jaeger |
O job e2e existe porque os outros dois nunca sobem o compose — uma quebra no Dockerfile ou na cadeia de observabilidade passaria despercebida.
Roteiro completo em docs/manual_validacao_ponta_a_ponta.md.
Todas em .env (ver .env.example):
| Variável | Padrão | Função |
|---|---|---|
SECRET_KEY |
chave de dev | Chave criptográfica do Django — trocar fora de dev |
DEBUG |
True |
Modo debug |
ALLOWED_HOSTS |
127.0.0.1,localhost |
Hosts aceitos, separados por vírgula |
DATABASE_URL |
sqlite:///db.sqlite3 |
Conexão via dj-database-url; aceita postgres://... |
OTEL_ENABLED |
False |
Liga a instrumentação OpenTelemetry |
OTEL_EXPORTER_OTLP_ENDPOINT |
http://localhost:4318 |
Coletor OTLP (Jaeger) |
OTEL_SERVICE_NAME |
labtelemetry |
Nome do serviço nos traces |
APP_PORT / POSTGRES_PORT |
8000 / 5432 |
Portas publicadas no host pelo Compose |
OTLP_GRPC_PORT / OTLP_HTTP_PORT / JAEGER_UI_PORT |
4317 / 4318 / 16686 |
Portas do Jaeger no host |
| Documento | Conteúdo |
|---|---|
| docs/overview.md | Escopo do projeto e posicionamento |
| docs/architecture.md | Estrutura de runtime e fronteiras entre componentes |
| docs/api.md | Endpoints e contrato público |
| docs/data-model.md | Modelo de dados operacional |
| docs/data-contract.md | Contrato de dados, garantias e limitações |
| docs/replay-idempotency.md | O que é garantido no replay — e o que não é |
| docs/operations.md | Setup e comandos operacionais |
| docs/manual_validacao_ponta_a_ponta.md | Validação end-to-end em terminais paralelos |
| docs/security.md | Fronteira de documentação pública e tratamento de segredos |
| sql/analytics/ | Consultas de frescor, volume e taxa de anomalia |
Aprofundamento na Wiki do projeto.
labtelemetry/
├── labtelemetry/ # Projeto Django
│ ├── labtelemetry/ # settings, urls, wsgi/asgi (OTel condicional)
│ └── telemetry/ # App único de domínio
│ ├── models.py # Sensor, Reading (UniqueConstraint), Alert
│ ├── quality.py # Regras de limite, drift e alerta idempotente
│ ├── views.py # API JSON + fragmentos HTMX do dashboard
│ ├── sources/ # Adapters OT sob a ABC TelemetrySource
│ │ ├── base.py # TelemetrySource, TelemetrySample
│ │ ├── modbus.py # Modbus TCP via pymodbus
│ │ ├── opcua.py # OPC-UA via asyncua (+ servidor de teste)
│ │ └── simulator.py # Gerador gaussiano determinístico
│ ├── management/commands/
│ │ ├── ingest_telemetry.py # Runner: fonte → qualidade → lote
│ │ └── simulate_telemetry.py # Gerador de cenário sintético
│ ├── templates/telemetry/ # Dashboard + parciais HTMX
│ └── test_*.py, tests.py # 85 testes
├── docs/ # Documentação pública + wiki-seed
├── sql/analytics/ # Consultas operacionais
├── .github/workflows/ci.yml # lint + testes/cobertura + e2e no compose
├── docker-compose.yml # app + postgres + jaeger
└── Dockerfile # Runtime Gunicorn
Este repositório é deliberadamente um laboratório local, não uma plataforma de produção generalizada. O que está fora de escopo está fora por decisão, não por omissão:
- Processamento de stream distribuído
- Autenticação de produção na API
- Infraestrutura cloud multi-região
- Garantia formal de exactly-once — o comportamento real e seus limites estão em replay-idempotency.md
MIT © 2026 Roberto Nascimento