diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..70ae8d1
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,6 @@
+VFM_DEFAULT_PASSWORD=admin
+VFM_APP_SECRET=
+VFM_LICENSE_MASTER_KEY=
+VFM_ENV=
+VFM_LOG_LEVEL=INFO
+PORT=8080
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f5bc35a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+__pycache__/
+*.pyc
+.pytest_cache/
+config_nodes.json
+mapa_dispositivos.json
+license.key
+app_secret.key
diff --git a/README.md b/README.md
index 3a911e6..449a56c 100644
--- a/README.md
+++ b/README.md
@@ -1,37 +1,97 @@
-# 🔥 V-Fire Monitor - Notifier Master (v1.0)
+# V-Fire Monitor
-O **V-Fire Monitor** é uma solução robusta de monitoramento e gestão para centrais de alarme de incêndio da linha **Notifier (NFS-320, NFS-640, NFS-3030)**. Desenvolvido para a **Voltec**, o software atua como um gateway inteligente entre o protocolo Modbus/TCP das placas BACNET-GW-3 e o sistema de monitoramento Zabbix, oferecendo uma interface web intuitiva e um sistema de licenciamento proprietário.
+Aplicacao Flask para monitoramento de centrais Notifier via Modbus/TCP, com painel web, descoberta de pontos, integracao com Zabbix e licenciamento por hardware.
----
+## O que mudou nesta refatoracao
-## 🚀 Funcionalidades Principais
+- Persistencia JSON com escrita atomica e migracao de configuracao legada.
+- Login com hash de senha em vez de senha em texto puro no arquivo de configuracao.
+- Chave secreta do Flask persistida localmente, sem valor fixo no codigo.
+- Validacao de payloads da API e respostas de erro consistentes.
+- Logging basico para diagnostico, no lugar de falhas silenciosas.
+- Polling Modbus e discovery por blocos, reduzindo chamadas individuais.
+- Frontend com tratamento de erro e textos corrigidos.
+- Gerador de licenca com argumentos de linha de comando e validacao de data.
-* **Monitoramento Multi-Node:** Gerenciamento de múltiplas centrais simultaneamente via IP/Modbus.
-* **Auto-Discovery Ultra:** Varredura automática de endereços Modbus (registradores 1-600) para identificação instantânea de dispositivos.
-* **Integração Nativa Zabbix:** * Envio de **LLD (Low Level Discovery)** para autocadastro de itens.
- * Monitoramento de **Status de Evento** (Alarme, Normal, Removido).
- * Monitoramento de **Integridade de Comunicação** (Link de dados do ponto).
-* **Gestão de Licenciamento:** * Proteção por **Hardware ID (HWID)** vinculada à máquina física.
- * Controle de expiração por data com contador regressivo no painel.
-* **Persistência de Dados:** Nomes personalizados de sensores são preservados no banco de dados local (`.json`), mesmo após novos scans.
-* **Interface Web Profissional:** Painel em *Dark Mode* com indicadores visuais coloridos e status em tempo real.
+## Estrutura
----
+- `monitor.py`: entrypoint simples da aplicacao Flask.
+- `vfire_monitor/__init__.py`: app factory e bootstrap da aplicacao.
+- `vfire_monitor/core.py`: regras de negocio, persistencia, licenca e engine de monitoramento.
+- `vfire_monitor/routes.py`: rotas web e APIs.
+- `generator.py`: gerador de serial de licenca.
+- `templates/`: telas do login e dashboard.
+- `tests/`: suite inicial de testes automatizados.
+- `config_nodes.json`: configuracao persistida da aplicacao.
+- `mapa_dispositivos.json`: mapa de dispositivos descobertos.
+- `license.key`: serial instalado localmente.
+- `app_secret.key`: segredo de sessao gerado automaticamente na primeira execucao.
-## 📂 Estrutura do Projeto
+## Requisitos
-* `monitor.py`: Core do sistema. Gerencia o loop de leitura Modbus, o servidor Flask e o envio de métricas via Zabbix Sender.
-* `generator.py`: Ferramenta administrativa da Voltec para gerar seriais de ativação criptografados.
-* `templates/`:
- * `login.html`: Interface de autenticação segura.
- * `index.html`: Dashboard principal com monitoramento de nós, sensores e licença.
-* `config_nodes.json`: Arquivo de persistência para IPs das centrais e endereços do servidor Zabbix.
-* `mapa_dispositivos.json`: Mapeamento detalhado de cada endereço Modbus e seu rótulo personalizado.
+- Python 3.10+
+- Conectividade com as centrais via Modbus/TCP
+- Acesso ao servidor Zabbix, quando a integracao estiver habilitada
----
+Instalacao:
-## 🛠️ Instalação e Requisitos
-
-### Dependências Python
```bash
-pip install flask flask-login pyModbusTCP zabbix-utils cryptography
\ No newline at end of file
+pip install -r requirements.txt
+```
+
+## Variaveis de ambiente
+
+Veja `.env.example`.
+
+As principais:
+
+- `VFM_DEFAULT_PASSWORD`: senha inicial do usuario `admin` na primeira carga do sistema.
+- `VFM_APP_SECRET`: opcional, substitui o segredo salvo em `app_secret.key`.
+- `VFM_LICENSE_MASTER_KEY`: chave mestre do licenciamento. Em producao, use esta variavel e remova a dependencia da chave legada.
+- `VFM_ENV`: use `production` para obrigar `VFM_LICENSE_MASTER_KEY` no startup.
+- `VFM_LOG_LEVEL`: nivel de log, por exemplo `INFO` ou `DEBUG`.
+- `PORT`: porta HTTP da aplicacao.
+
+## Execucao
+
+```bash
+python monitor.py
+```
+
+O sistema sobe em `http://0.0.0.0:8080` por padrao.
+
+## Geracao de licenca
+
+Modo interativo:
+
+```bash
+python generator.py
+```
+
+Modo por argumentos:
+
+```bash
+python generator.py --hwid "UUID-DO-CLIENTE" --cliente "Cliente" --expira 2026-12-31
+```
+
+## Observacoes operacionais
+
+- A senha do painel fica armazenada como hash em `config_nodes.json`.
+- Se existir configuracao antiga com `web_password`, ela e migrada automaticamente para `web_password_hash`.
+- Em ambiente de desenvolvimento, o sistema ainda aceita a chave de licenca legada embutida para manter compatibilidade.
+- Em ambiente de producao (`VFM_ENV=production`), `VFM_LICENSE_MASTER_KEY` passa a ser obrigatoria e o sistema falha no startup sem ela.
+- Bootstrap continua sendo carregado via CDN. Se o ambiente nao tiver acesso externo, copie os assets localmente e ajuste os templates.
+
+## Testes
+
+Executar:
+
+```bash
+pytest
+```
+
+## Proximos passos recomendados
+
+- Adicionar testes cobrindo polling Modbus e integracao com Zabbix com doubles dedicados.
+- Separar configuracao e logging em modulos proprios se a aplicacao continuar crescendo.
+- Trocar Bootstrap via CDN por assets locais se o ambiente alvo nao tiver acesso externo.
diff --git a/generator.py b/generator.py
index 06aa5e8..a5b63eb 100644
--- a/generator.py
+++ b/generator.py
@@ -1,16 +1,58 @@
-from cryptography.fernet import Fernet
-import json
-
-# CHAVE MESTRA - DEVE SER A MESMA NO MONITOR.PY
-MASTER_KEY = b'vS-C5Z_R1ST-Gf_K8_L9_Xo2-M1A3B5C7D9E1F2G3H4='
-
-def gerar_serial(hardware_id, cliente, data_expiracao):
- f = Fernet(MASTER_KEY)
- dados = {"h": hardware_id.strip(), "c": cliente, "e": data_expiracao}
- return f.encrypt(json.dumps(dados).encode()).decode()
-
-print("--- GERADOR DE LICENÇA VOLTEC ---")
-hwid = input("Hardware ID do cliente: ")
-nome = input("Nome do Cliente: ")
-validade = input("Validade (AAAA-MM-DD): ")
-print(f"\nSERIAL:\n{gerar_serial(hwid, nome, validade)}\n")
\ No newline at end of file
+import argparse
+import json
+import os
+from datetime import datetime
+
+from cryptography.fernet import Fernet
+
+
+LEGACY_LICENSE_KEY = b"vS-C5Z_R1ST-Gf_K8_L9_Xo2-M1A3B5C7D9E1F2G3H4="
+
+
+def load_master_key() -> bytes:
+ env_key = os.getenv("VFM_LICENSE_MASTER_KEY")
+ if env_key:
+ return env_key.encode("utf-8")
+ return LEGACY_LICENSE_KEY
+
+
+def parse_args():
+ parser = argparse.ArgumentParser(description="Gerador de licenca do V-Fire Monitor")
+ parser.add_argument("--hwid", help="Hardware ID do cliente")
+ parser.add_argument("--cliente", help="Nome do cliente")
+ parser.add_argument("--expira", help="Data de expiracao no formato AAAA-MM-DD")
+ return parser.parse_args()
+
+
+def ask_if_missing(value: str, prompt: str) -> str:
+ return value if value else input(prompt).strip()
+
+
+def validate_expiration(date_text: str) -> str:
+ datetime.strptime(date_text, "%Y-%m-%d")
+ return date_text
+
+
+def gerar_serial(hardware_id: str, cliente: str, data_expiracao: str) -> str:
+ fernet = Fernet(load_master_key())
+ payload = {"h": hardware_id.strip(), "c": cliente.strip(), "e": validate_expiration(data_expiracao)}
+ return fernet.encrypt(json.dumps(payload, ensure_ascii=False).encode("utf-8")).decode("utf-8")
+
+
+def main():
+ args = parse_args()
+
+ hardware_id = ask_if_missing(args.hwid, "Hardware ID do cliente: ")
+ cliente = ask_if_missing(args.cliente, "Nome do cliente: ")
+ expiracao = ask_if_missing(args.expira, "Validade (AAAA-MM-DD): ")
+
+ if not hardware_id:
+ raise SystemExit("Hardware ID obrigatorio.")
+ if not cliente:
+ raise SystemExit("Nome do cliente obrigatorio.")
+
+ print(gerar_serial(hardware_id, cliente, expiracao))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/monitor.py b/monitor.py
index 1864291..53d6f93 100644
--- a/monitor.py
+++ b/monitor.py
@@ -1,241 +1,10 @@
-import time, json, os, threading, subprocess
-from datetime import datetime
-from flask import Flask, render_template, jsonify, request, redirect, url_for
-from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user
-from pyModbusTCP.client import ModbusClient
-from zabbix_utils import Sender, ItemValue
-from cryptography.fernet import Fernet
-
-app = Flask(__name__)
-app.secret_key = 'voltec_master_final_ultra_v6'
-
-# --- 1. DEFINIÇÕES GLOBAIS E PERSISTÊNCIA (Devem vir antes das rotas) ---
-ARQUIVO_CONFIG = 'config_nodes.json'
-ARQUIVO_MAPA = 'mapa_dispositivos.json'
-ARQUIVO_LICENSA = 'license.key'
-MASTER_KEY = b'vS-C5Z_R1ST-Gf_K8_L9_Xo2-M1A3B5C7D9E1F2G3H4='
-
-def carregar_dados(arquivo, padrao):
- if os.path.exists(arquivo):
- try:
- with open(arquivo, 'r') as f: return json.load(f)
- except: return padrao
- return padrao
-
-def salvar_dados(arquivo, dados):
- with open(arquivo, 'w') as f: json.dump(dados, f, indent=4)
-
-# Carregamos a config aqui para o login não dar erro
-config_global = carregar_dados(ARQUIVO_CONFIG, {
- "zabbix_server": "172.16.33.6",
- "hostname_zabbix": "NFS-320",
- "web_user": "admin",
- "web_password": "admin",
- "nodes": []
-})
-mapa_dispositivos = carregar_dados(ARQUIVO_MAPA, {})
-dados_tempo_real = {}
-status_nodes_web = {}
-deve_reiniciar = False
-
-MAPA_STATUS_BASE = {
- 5120: {"label": "NORMAL / OK", "color": "#28a745"},
- 3088: {"label": "INCIDENTE", "color": "#dc3545"},
- 3344: {"label": "INCIDENTE ACK", "color": "#007bff"},
- 13312: {"label": "REMOVIDO", "color": "#fd7e14"},
- 0: {"label": "VAZIO", "color": "#6c757d"}
-}
-
-# --- 2. GESTÃO DE ACESSO ---
-login_manager = LoginManager()
-login_manager.init_app(app)
-login_manager.login_view = 'login'
-
-class User(UserMixin):
- def __init__(self, id): self.id = id
-
-@login_manager.user_loader
-def load_user(user_id): return User(user_id)
-
-# --- 3. AUXILIARES E LICENCIAMENTO (VERSÃO COM DIAS) ---
-def get_status_info(valor):
- lsb = valor & 0xFF
- return MAPA_STATUS_BASE.get(valor, MAPA_STATUS_BASE.get(lsb, {"label": f"ST {valor}", "color": "#6c757d"}))
-
-def get_hardware_id():
- try:
- if os.name == 'nt':
- cmd = 'powershell "(Get-CimInstance Win32_ComputerSystemProduct).UUID.Trim()"'
- return subprocess.check_output(cmd, shell=True).decode().strip()
- return subprocess.check_output('cat /etc/machine-id', shell=True).decode().strip()[:32]
- except: return "ID-ERRO-VOLTEC-001"
-
-def verificar_licenca():
- if not os.path.exists(ARQUIVO_LICENSA):
- return False, "Licença ausente", get_hardware_id(), 0
- try:
- f = Fernet(MASTER_KEY)
- with open(ARQUIVO_LICENSA, 'r') as file:
- serial = file.read().strip()
-
- dados = json.loads(f.decrypt(serial.encode()).decode())
-
- if dados['h'].strip() != get_hardware_id().strip():
- return False, "HWID Incompatível", get_hardware_id(), 0
-
- expira = datetime.strptime(dados['e'], '%Y-%m-%d')
- dias_restantes = (expira - datetime.now()).days
-
- if dias_restantes < 0:
- return False, "Expirada", get_hardware_id(), 0
-
- return True, f"Licenciado para {dados['c']}", get_hardware_id(), dias_restantes
- except:
- return False, "Serial Inválido", get_hardware_id(), 0
-
-# --- 4. MOTOR MODBUS E BACKGROUND ---
-def realizar_discovery_node(node, mapa_atual):
- client = ModbusClient(host=node['ip'], port=502, unit_id=int(node['unit']), auto_open=True, timeout=0.5)
- mapa_node = {}
- for addr in range(1, 601):
- res = client.read_holding_registers(addr, 1)
- if res and res[0] not in [0, 255, 65535]:
- key = f"{node['ip']}_{node['unit']}_{addr}"
- if key in mapa_atual: label = mapa_atual[key]['label']
- else:
- tipo = 'D' if addr < 160 else ('M' if addr < 350 else 'P')
- label = f"{node['nome']}-{tipo}{addr:03}"
- mapa_node[key] = {"label": label, "status": res[0], "node_nome": node['nome']}
- client.close()
- return mapa_node
-
-def monitorar_background():
- global dados_tempo_real, deve_reiniciar, config_global, mapa_dispositivos, status_nodes_web
- while True:
- licenciado, _, _, _ = verificar_licenca()
- if not licenciado:
- status_nodes_web = {"SISTEMA": "BLOQUEADO"}
- time.sleep(5); continue
-
- config_global = carregar_dados(ARQUIVO_CONFIG, config_global)
- mapa_dispositivos = carregar_dados(ARQUIVO_MAPA, mapa_dispositivos)
- sender = Sender(server=config_global['zabbix_server'])
- hostname = config_global['hostname_zabbix']
-
- # Discovery LLD (Zabbix re-cadastra nomes se houver mudança)
- if mapa_dispositivos:
- lld = {"data": []}
- for k, info in mapa_dispositivos.items(): lld["data"].append({"{#PONTO_LABEL}": info["label"]})
- for n in config_global['nodes']: lld["data"].append({"{#NODE_NOME}": n["nome"]})
- try: sender.send([ItemValue(hostname, "notifier.discovery", json.dumps(lld))])
- except: pass
-
- while not deve_reiniciar:
- metrics = []
- for node in config_global['nodes']:
- c = ModbusClient(host=node['ip'], port=502, unit_id=int(node['unit']), auto_open=True, timeout=1)
- res = c.read_holding_registers(1, 1)
- status_conn = 1 if res is not None else 0
- status_nodes_web[node['nome']] = "Online" if status_conn == 1 else "Offline"
- metrics.append(ItemValue(hostname, f'node.status[{node["nome"]}]', status_conn))
- if status_conn == 1:
- for key, info in list(mapa_dispositivos.items()):
- if key.startswith(f"{node['ip']}_{node['unit']}_"):
- addr = int(key.split('_')[-1])
- r = c.read_holding_registers(addr, 1)
- if r:
- val = r[0]
- dados_tempo_real[key] = {"label": info['label'], "status": val, "node": node['nome'], "last": time.strftime('%H:%M:%S')}
- metrics.append(ItemValue(hostname, f'notifier.status[{info["label"]}]', val & 0xFF))
- metrics.append(ItemValue(hostname, f'notifier.comm[{info["label"]}]', (val >> 8) & 0xFF))
- c.close()
- if metrics:
- try: sender.send(metrics)
- except: pass
- time.sleep(2)
- deve_reiniciar = False
-
-# --- 5. ROTAS ---
-@app.route('/login', methods=['GET', 'POST'])
-def login():
- if request.method == 'POST':
- # Aqui o config_global agora existe!
- if request.form.get('username') == config_global['web_user'] and \
- request.form.get('password') == config_global['web_password']:
- login_user(User(config_global['web_user']))
- return redirect(url_for('index'))
- return render_template('login.html')
-
-@app.route('/')
-@login_required
-def index():
- # Agora recebemos 4 valores da função
- lic, msg, hwid, dias = verificar_licenca()
- return render_template('index.html',
- config=config_global,
- licenciado=lic,
- lic_msg=msg,
- hwid=hwid,
- dias_restantes=dias)
-
-@app.route('/api/nodes', methods=['POST'])
-@login_required
-def api_nodes():
- global deve_reiniciar, config_global, mapa_dispositivos
- data = request.json
- config_global.update({'nodes': data['nodes'], 'zabbix_server': data['zabbix_server'], 'hostname_zabbix': data['hostname_zabbix']})
- salvar_dados(ARQUIVO_CONFIG, config_global)
- ips_ativos = [f"{n['ip']}_{n['unit']}_" for n in data['nodes']]
- mapa_dispositivos = {k: v for k, v in mapa_dispositivos.items() if any(k.startswith(p) for p in ips_ativos)}
- salvar_dados(ARQUIVO_MAPA, mapa_dispositivos)
- deve_reiniciar = True
- return jsonify({"status": "success"})
-
-@app.route('/api/rename', methods=['POST'])
-@login_required
-def api_rename():
- global deve_reiniciar
- data = request.json
- if data['addr'] in mapa_dispositivos:
- mapa_dispositivos[data['addr']]['label'] = data['label']
- salvar_dados(ARQUIVO_MAPA, mapa_dispositivos)
- deve_reiniciar = True # Força envio do novo nome ao Zabbix
- return jsonify({"status": "success"})
- return jsonify({"status": "error"}), 404
-
-@app.route('/api/license', methods=['POST'])
-def api_license():
- global deve_reiniciar
- with open(ARQUIVO_LICENSA, 'w') as f: f.write(request.json['serial'].strip())
- deve_reiniciar = True
- return jsonify({"status": "success"})
-
-@app.route('/api/rescan_node', methods=['POST'])
-@login_required
-def api_rescan():
- global deve_reiniciar, mapa_dispositivos
- node_data = request.json
- res_mapa = realizar_discovery_node(node_data, mapa_dispositivos)
- prefixo = f"{node_data['ip']}_{node_data['unit']}_"
- mapa_dispositivos = {k: v for k, v in mapa_dispositivos.items() if not k.startswith(prefixo)}
- mapa_dispositivos.update(res_mapa)
- salvar_dados(ARQUIVO_MAPA, mapa_dispositivos)
- deve_reiniciar = True
- return jsonify({"status": "success"})
-
-@app.route('/api/data')
-@login_required
-def api_data():
- lista = []
- for key, info in mapa_dispositivos.items():
- tr = dados_tempo_real.get(key, {"status": 0, "last": "--:--:--", "node": info.get('node_nome', '...')})
- st_info = get_status_info(tr['status'])
- lista.append({"key": key, "label": info['label'], "node": tr.get('node', '...'), "status_text": st_info['label'], "color": st_info['color'], "last": tr['last']})
- return jsonify({"sensores": lista, "nodes_status": status_nodes_web})
-
-@app.route('/logout')
-def logout(): logout_user(); return redirect(url_for('login'))
-
-if __name__ == "__main__":
- threading.Thread(target=monitorar_background, daemon=True).start()
- app.run(host='0.0.0.0', port=8080)
\ No newline at end of file
+import os
+
+from vfire_monitor import create_app
+
+
+app = create_app()
+
+
+if __name__ == "__main__":
+ app.run(host="0.0.0.0", port=int(os.getenv("PORT", "8080")), debug=False)
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..ce79877
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,6 @@
+Flask>=3.0,<4.0
+Flask-Login>=0.6,<1.0
+pyModbusTCP>=0.3,<1.0
+zabbix-utils>=2.0,<3.0
+cryptography>=42.0,<45.0
+pytest>=8.0,<9.0
diff --git a/templates/index.html b/templates/index.html
index ae2e9ca..a50b821 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -1,169 +1,429 @@
-
-
-
-
- Notifier Master - Voltec
-
-
-
-
-
-
-
-
Varrendo central (1-600)...
-
-
-
-
-
- {% if not licenciado %}
-
-
-
🔒 Sistema Bloqueado
-
ID: {{ hwid }}
-
-
-
-
- {% else %}
-
-
-
- ⏳ {{ dias_restantes }} dias restantes
-
-
-
-
-
- {{ lic_msg }}
-
-
-
-
-
- {% endif %}
-
-
-
-
-
-
-
-
\ No newline at end of file
+
+
+
+
+
+ V-Fire Monitor
+
+
+
+
+
+
+
+
Executando varredura Modbus...
+
+
+
+
+
+
+
+
+ {% if not licenciado %}
+
+
+
+
Sistema bloqueado
+
Ative a licenca deste equipamento para liberar o monitoramento.
+
Hardware ID
+
{{ hwid }}
+
+
+
+
+
+ {% else %}
+
+
+
Licenca
+
{{ lic_msg }}
+
+
+ {{ dias_restantes }} dias restantes
+
+
+
+
+ {% endif %}
+
+
+
+
+
+
+
+
diff --git a/templates/login.html b/templates/login.html
index 6ec1ecf..ee374da 100644
--- a/templates/login.html
+++ b/templates/login.html
@@ -1,22 +1,55 @@
-
-
-
- Login - Notifier
-
-
-
-
-
-
-
\ No newline at end of file
+
+
+
+
+
+ Login | V-Fire Monitor
+
+
+
+
+
+ Voltec
+ Acesso ao V-Fire Monitor
+
+ {% with messages = get_flashed_messages() %}
+ {% if messages %}
+ {{ messages[0] }}
+ {% endif %}
+ {% endwith %}
+
+
+
+
+
diff --git a/tests/test_core.py b/tests/test_core.py
new file mode 100644
index 0000000..afb1d8d
--- /dev/null
+++ b/tests/test_core.py
@@ -0,0 +1,119 @@
+import json
+from pathlib import Path
+
+import pytest
+from cryptography.fernet import Fernet
+from werkzeug.security import check_password_hash
+
+from vfire_monitor import create_app
+from vfire_monitor.core import (
+ LicenseService,
+ contiguous_ranges,
+ load_license_key,
+ normalize_config,
+ validate_node_payload,
+)
+
+
+def test_validate_node_payload_normalizes_fields():
+ node = validate_node_payload({"nome": " Central A ", "ip": " 10.0.0.1 ", "unit": "3"})
+ assert node == {"nome": "Central A", "ip": "10.0.0.1", "unit": 3}
+
+
+def test_validate_node_payload_rejects_invalid_unit():
+ with pytest.raises(ValueError):
+ validate_node_payload({"nome": "Central A", "ip": "10.0.0.1", "unit": 999})
+
+
+def test_normalize_config_migrates_legacy_password():
+ config = normalize_config(
+ {
+ "web_user": "admin",
+ "web_password": "segredo",
+ "nodes": [{"nome": "Node 1", "ip": "1.1.1.1", "unit": 1}],
+ },
+ default_password="admin",
+ )
+
+ assert "web_password" not in config
+ assert check_password_hash(config["web_password_hash"], "segredo")
+ assert config["nodes"][0]["nome"] == "Node 1"
+
+
+def test_contiguous_ranges_splits_large_blocks():
+ ranges = contiguous_ranges(list(range(1, 125)))
+ assert ranges == [(1, 120), (121, 124)]
+
+
+def test_license_service_validates_serial(tmp_path, monkeypatch):
+ monkeypatch.setattr("vfire_monitor.core.get_hardware_id", lambda: "HW-123")
+ key = Fernet.generate_key()
+ payload = {"h": "HW-123", "c": "Cliente", "e": "2099-01-01"}
+ serial = Fernet(key).encrypt(json.dumps(payload).encode("utf-8")).decode("utf-8")
+ license_path = tmp_path / "license.key"
+ license_path.write_text(serial, encoding="utf-8")
+
+ service = LicenseService(license_path, key)
+ status = service.verify()
+
+ assert status.valid is True
+ assert "Cliente" in status.message
+
+
+def test_load_license_key_requires_env_in_production(monkeypatch):
+ monkeypatch.delenv("VFM_LICENSE_MASTER_KEY", raising=False)
+ with pytest.raises(RuntimeError):
+ load_license_key(require_env=True)
+
+
+@pytest.fixture
+def app(tmp_path, monkeypatch):
+ monkeypatch.setenv("VFM_DEFAULT_PASSWORD", "admin")
+ monkeypatch.setenv("VFM_LICENSE_MASTER_KEY", Fernet.generate_key().decode("utf-8"))
+ application = create_app(base_dir=tmp_path, start_monitor=False)
+ application.config["TESTING"] = True
+
+ runtime_state = application.extensions["runtime_state"]
+ runtime_state.config["web_user"] = "admin"
+ runtime_state.config["web_password_hash"] = normalize_config({}, "admin")["web_password_hash"]
+ runtime_state.save_config()
+ return application
+
+
+@pytest.fixture
+def client(app):
+ return app.test_client()
+
+
+def login(client):
+ return client.post("/login", data={"username": "admin", "password": "admin"}, follow_redirects=False)
+
+
+def test_login_success(client):
+ response = login(client)
+ assert response.status_code == 302
+
+
+def test_api_nodes_requires_auth(client):
+ response = client.post("/api/nodes", json={})
+ assert response.status_code == 302
+
+
+def test_api_nodes_updates_configuration(client, app):
+ login(client)
+ response = client.post(
+ "/api/nodes",
+ json={
+ "zabbix_server": "127.0.0.1",
+ "hostname_zabbix": "HOST-01",
+ "nodes": [{"nome": "Central A", "ip": "10.0.0.5", "unit": 3}],
+ },
+ )
+
+ assert response.status_code == 200
+ assert response.get_json()["status"] == "success"
+
+ config_path = Path(app.extensions["runtime_state"].config_store.path)
+ config = json.loads(config_path.read_text(encoding="utf-8"))
+ assert config["hostname_zabbix"] == "HOST-01"
+ assert config["nodes"][0]["nome"] == "Central A"
diff --git a/vfire_monitor/__init__.py b/vfire_monitor/__init__.py
new file mode 100644
index 0000000..39903cd
--- /dev/null
+++ b/vfire_monitor/__init__.py
@@ -0,0 +1,68 @@
+import logging
+import os
+from pathlib import Path
+
+from flask import Flask
+from flask_login import LoginManager
+
+from .core import (
+ LicenseService,
+ MonitorEngine,
+ RuntimeState,
+ User,
+ ensure_secret_key,
+ load_license_key,
+)
+from .routes import register_routes
+
+
+def configure_logging() -> None:
+ logging.basicConfig(
+ level=os.getenv("VFM_LOG_LEVEL", "INFO").upper(),
+ format="%(asctime)s [%(levelname)s] %(message)s",
+ )
+
+
+def is_production_mode() -> bool:
+ value = os.getenv("VFM_ENV", "").strip().lower()
+ return value in {"prod", "production"}
+
+
+def create_app(base_dir: str | Path | None = None, start_monitor: bool = True) -> Flask:
+ configure_logging()
+
+ resolved_base_dir = Path(base_dir) if base_dir else Path(__file__).resolve().parent.parent
+ secret_path = resolved_base_dir / "app_secret.key"
+ default_password = os.getenv("VFM_DEFAULT_PASSWORD", "admin")
+
+ app = Flask(
+ __name__,
+ template_folder=str(resolved_base_dir / "templates"),
+ )
+ app.secret_key = os.getenv("VFM_APP_SECRET") or ensure_secret_key(secret_path)
+
+ runtime_state = RuntimeState(resolved_base_dir, default_password)
+ license_service = LicenseService(
+ resolved_base_dir / "license.key",
+ load_license_key(require_env=is_production_mode()),
+ )
+
+ app.extensions["runtime_state"] = runtime_state
+ app.extensions["license_service"] = license_service
+
+ login_manager = LoginManager()
+ login_manager.init_app(app)
+ login_manager.login_view = "login"
+
+ @login_manager.user_loader
+ def load_user(user_id):
+ return User(user_id)
+
+ register_routes(app)
+
+ if start_monitor:
+ monitor_engine = MonitorEngine(runtime_state, license_service)
+ app.extensions["monitor_engine"] = monitor_engine
+ monitor_engine.start()
+
+ return app
diff --git a/vfire_monitor/core.py b/vfire_monitor/core.py
new file mode 100644
index 0000000..2ba693d
--- /dev/null
+++ b/vfire_monitor/core.py
@@ -0,0 +1,494 @@
+import json
+import logging
+import os
+import threading
+import time
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
+from typing import Dict, List, Tuple
+
+from cryptography.fernet import Fernet, InvalidToken
+from pyModbusTCP.client import ModbusClient
+from werkzeug.security import check_password_hash, generate_password_hash
+from zabbix_utils import ItemValue, Sender
+
+
+DEFAULT_CONFIG_TEMPLATE = {
+ "zabbix_server": "172.16.33.6",
+ "hostname_zabbix": "NFS-320",
+ "web_user": "admin",
+ "nodes": [],
+}
+LEGACY_LICENSE_KEY = b"vS-C5Z_R1ST-Gf_K8_L9_Xo2-M1A3B5C7D9E1F2G3H4="
+MAX_DISCOVERY_ADDR = 600
+MODBUS_BLOCK_SIZE = 120
+DISCOVERY_IGNORED_VALUES = {0, 255, 65535}
+POLL_INTERVAL_SECONDS = 2
+RETRY_INTERVAL_SECONDS = 5
+
+MAPA_STATUS_BASE = {
+ 5120: {"label": "NORMAL / OK", "color": "#28a745"},
+ 3088: {"label": "INCIDENTE", "color": "#dc3545"},
+ 3344: {"label": "INCIDENTE ACK", "color": "#007bff"},
+ 13312: {"label": "REMOVIDO", "color": "#fd7e14"},
+ 0: {"label": "VAZIO", "color": "#6c757d"},
+}
+
+LOGGER = logging.getLogger("v-fire-monitor")
+
+
+class User:
+ def __init__(self, user_id: str):
+ self.id = user_id
+
+ @property
+ def is_authenticated(self) -> bool:
+ return True
+
+ @property
+ def is_active(self) -> bool:
+ return True
+
+ @property
+ def is_anonymous(self) -> bool:
+ return False
+
+ def get_id(self) -> str:
+ return self.id
+
+
+class JsonStore:
+ def __init__(self, path: Path):
+ self.path = path
+
+ def load(self, default):
+ if not self.path.exists():
+ return default
+ try:
+ with self.path.open("r", encoding="utf-8") as handle:
+ return json.load(handle)
+ except (json.JSONDecodeError, OSError) as exc:
+ LOGGER.warning("Falha ao carregar %s: %s", self.path.name, exc)
+ return default
+
+ def save(self, data) -> None:
+ self.path.parent.mkdir(parents=True, exist_ok=True)
+ temp_path = self.path.with_suffix(f"{self.path.suffix}.tmp")
+ with temp_path.open("w", encoding="utf-8") as handle:
+ json.dump(data, handle, indent=4, ensure_ascii=False)
+ temp_path.replace(self.path)
+
+
+def build_default_config(default_password: str) -> dict:
+ config = dict(DEFAULT_CONFIG_TEMPLATE)
+ config["web_password_hash"] = generate_password_hash(default_password)
+ return config
+
+
+def ensure_secret_key(secret_path: Path) -> str:
+ if secret_path.exists():
+ return secret_path.read_text(encoding="utf-8").strip()
+ secret = Fernet.generate_key().decode("ascii")
+ secret_path.write_text(secret, encoding="utf-8")
+ LOGGER.info("Chave de sessão criada em %s", secret_path.name)
+ return secret
+
+
+def normalize_config(data: dict, default_password: str) -> dict:
+ config = build_default_config(default_password)
+ config.update(data or {})
+
+ if config.get("web_password") and not config.get("web_password_hash"):
+ config["web_password_hash"] = generate_password_hash(config["web_password"])
+
+ config.pop("web_password", None)
+
+ normalized_nodes = []
+ for raw_node in config.get("nodes", []):
+ try:
+ normalized_nodes.append(validate_node_payload(raw_node))
+ except ValueError as exc:
+ LOGGER.warning("Node ignorado durante normalizacao: %s", exc)
+ config["nodes"] = normalized_nodes
+ return config
+
+
+def load_license_key(require_env: bool) -> bytes:
+ env_key = os.getenv("VFM_LICENSE_MASTER_KEY")
+ if env_key:
+ return env_key.encode("utf-8")
+ if require_env:
+ raise RuntimeError(
+ "VFM_LICENSE_MASTER_KEY e obrigatoria em producao. Defina a variavel de ambiente antes de iniciar o sistema."
+ )
+ LOGGER.warning(
+ "VFM_LICENSE_MASTER_KEY nao definido. Usando chave legada embutida; mova a chave para variavel de ambiente."
+ )
+ return LEGACY_LICENSE_KEY
+
+
+def get_status_info(value: int) -> dict:
+ low_byte = value & 0xFF
+ return MAPA_STATUS_BASE.get(
+ value,
+ MAPA_STATUS_BASE.get(low_byte, {"label": f"ST {value}", "color": "#6c757d"}),
+ )
+
+
+def get_hardware_id() -> str:
+ try:
+ if os.name == "nt":
+ import subprocess
+
+ output = subprocess.check_output(
+ [
+ "powershell",
+ "-NoProfile",
+ "-Command",
+ "(Get-CimInstance Win32_ComputerSystemProduct).UUID.Trim()",
+ ],
+ text=True,
+ )
+ return output.strip()
+
+ machine_id = Path("/etc/machine-id")
+ if machine_id.exists():
+ return machine_id.read_text(encoding="utf-8").strip()[:32]
+ except Exception as exc: # pragma: no cover
+ LOGGER.warning("Nao foi possivel obter hardware id: %s", exc)
+ return "ID-ERRO-VOLTEC-001"
+
+
+def validate_node_payload(data: dict) -> dict:
+ if not isinstance(data, dict):
+ raise ValueError("Formato de node invalido.")
+
+ nome = str(data.get("nome", "")).strip()
+ ip = str(data.get("ip", "")).strip()
+
+ if not nome:
+ raise ValueError("Nome do node e obrigatorio.")
+ if not ip:
+ raise ValueError("IP do node e obrigatorio.")
+
+ try:
+ unit = int(data.get("unit"))
+ except (TypeError, ValueError):
+ raise ValueError(f"Unit invalido para node {nome}.") from None
+
+ if unit < 0 or unit > 255:
+ raise ValueError(f"Unit fora do intervalo para node {nome}.")
+
+ return {"nome": nome, "ip": ip, "unit": unit}
+
+
+def contiguous_ranges(addresses: List[int]) -> List[Tuple[int, int]]:
+ if not addresses:
+ return []
+
+ sorted_addresses = sorted(set(addresses))
+ ranges: List[Tuple[int, int]] = []
+ start = sorted_addresses[0]
+ previous = start
+
+ for address in sorted_addresses[1:]:
+ if address == previous + 1 and (address - start + 1) <= MODBUS_BLOCK_SIZE:
+ previous = address
+ continue
+ ranges.append((start, previous))
+ start = previous = address
+ ranges.append((start, previous))
+ return ranges
+
+
+def discover_node(node: dict, current_map: dict) -> dict:
+ client = ModbusClient(
+ host=node["ip"],
+ port=502,
+ unit_id=node["unit"],
+ auto_open=True,
+ timeout=1,
+ )
+ discovered = {}
+
+ try:
+ for start in range(1, MAX_DISCOVERY_ADDR + 1, MODBUS_BLOCK_SIZE):
+ count = min(MODBUS_BLOCK_SIZE, MAX_DISCOVERY_ADDR - start + 1)
+ registers = client.read_holding_registers(start, count)
+ if not registers:
+ continue
+
+ for offset, value in enumerate(registers):
+ if value in DISCOVERY_IGNORED_VALUES:
+ continue
+ address = start + offset
+ key = f"{node['ip']}_{node['unit']}_{address}"
+ existing = current_map.get(key, {})
+ if existing.get("label"):
+ label = existing["label"]
+ else:
+ device_type = "D" if address < 160 else ("M" if address < 350 else "P")
+ label = f"{node['nome']}-{device_type}{address:03}"
+ discovered[key] = {
+ "label": label,
+ "status": value,
+ "node_nome": node["nome"],
+ }
+ finally:
+ client.close()
+
+ return discovered
+
+
+@dataclass
+class LicenseStatus:
+ valid: bool
+ message: str
+ hardware_id: str
+ days_remaining: int
+
+
+class LicenseService:
+ def __init__(self, license_path: Path, master_key: bytes):
+ self.license_path = license_path
+ self.fernet = Fernet(master_key)
+
+ def verify(self) -> LicenseStatus:
+ hardware_id = get_hardware_id()
+ if not self.license_path.exists():
+ return LicenseStatus(False, "Licenca ausente", hardware_id, 0)
+
+ try:
+ serial = self.license_path.read_text(encoding="utf-8").strip()
+ payload = json.loads(self.fernet.decrypt(serial.encode("utf-8")).decode("utf-8"))
+ licensed_hwid = str(payload["h"]).strip()
+ if licensed_hwid != hardware_id.strip():
+ return LicenseStatus(False, "HWID incompativel", hardware_id, 0)
+
+ expires_at = datetime.strptime(payload["e"], "%Y-%m-%d")
+ days_remaining = (expires_at.date() - datetime.now().date()).days
+ if days_remaining < 0:
+ return LicenseStatus(False, "Licenca expirada", hardware_id, 0)
+
+ return LicenseStatus(True, f"Licenciado para {payload['c']}", hardware_id, days_remaining)
+ except (InvalidToken, KeyError, ValueError, OSError, json.JSONDecodeError) as exc:
+ LOGGER.warning("Falha ao verificar licenca: %s", exc)
+ return LicenseStatus(False, "Serial invalido", hardware_id, 0)
+
+ def save(self, serial: str) -> None:
+ cleaned = serial.strip()
+ if not cleaned:
+ raise ValueError("Serial vazio.")
+ self.license_path.write_text(cleaned, encoding="utf-8")
+
+
+class RuntimeState:
+ def __init__(self, base_dir: Path, default_password: str):
+ self.lock = threading.RLock()
+ self.config_store = JsonStore(base_dir / "config_nodes.json")
+ self.map_store = JsonStore(base_dir / "mapa_dispositivos.json")
+ self.config = normalize_config(self.config_store.load(build_default_config(default_password)), default_password)
+ self.mapa_dispositivos = self.map_store.load({})
+ self.dados_tempo_real: Dict[str, dict] = {}
+ self.status_nodes_web: Dict[str, str] = {}
+ self.restart_event = threading.Event()
+ self.stop_event = threading.Event()
+ self.save_config()
+
+ def snapshot(self) -> Tuple[dict, dict, dict, dict]:
+ with self.lock:
+ return (
+ dict(self.config),
+ dict(self.mapa_dispositivos),
+ dict(self.dados_tempo_real),
+ dict(self.status_nodes_web),
+ )
+
+ def save_config(self) -> None:
+ with self.lock:
+ self.config_store.save(self.config)
+
+ def trigger_restart(self) -> None:
+ self.restart_event.set()
+
+ def consume_restart(self) -> bool:
+ if self.restart_event.is_set():
+ self.restart_event.clear()
+ return True
+ return False
+
+ def authenticate(self, username: str, password: str) -> bool:
+ with self.lock:
+ if username != self.config["web_user"]:
+ return False
+ return check_password_hash(self.config["web_password_hash"], password)
+
+ def update_config(self, zabbix_server: str, hostname_zabbix: str, nodes: List[dict]) -> None:
+ with self.lock:
+ self.config["zabbix_server"] = zabbix_server.strip()
+ self.config["hostname_zabbix"] = hostname_zabbix.strip()
+ self.config["nodes"] = nodes
+
+ active_prefixes = [f"{node['ip']}_{node['unit']}_" for node in nodes]
+ self.mapa_dispositivos = {
+ key: value
+ for key, value in self.mapa_dispositivos.items()
+ if any(key.startswith(prefix) for prefix in active_prefixes)
+ }
+
+ self.config_store.save(self.config)
+ self.map_store.save(self.mapa_dispositivos)
+ self.restart_event.set()
+
+ def rename_device(self, address_key: str, label: str) -> bool:
+ with self.lock:
+ if address_key not in self.mapa_dispositivos:
+ return False
+ self.mapa_dispositivos[address_key]["label"] = label.strip()
+ self.map_store.save(self.mapa_dispositivos)
+ self.restart_event.set()
+ return True
+
+ def replace_node_devices(self, node: dict, discovered_map: dict) -> None:
+ prefix = f"{node['ip']}_{node['unit']}_"
+ with self.lock:
+ self.mapa_dispositivos = {
+ key: value for key, value in self.mapa_dispositivos.items() if not key.startswith(prefix)
+ }
+ self.mapa_dispositivos.update(discovered_map)
+ self.map_store.save(self.mapa_dispositivos)
+ self.restart_event.set()
+
+
+class MonitorEngine:
+ def __init__(self, runtime_state: RuntimeState, license_service: LicenseService):
+ self.runtime_state = runtime_state
+ self.license_service = license_service
+
+ def start(self) -> None:
+ threading.Thread(target=self.run, daemon=True, name="monitor-engine").start()
+
+ def run(self) -> None:
+ while not self.runtime_state.stop_event.is_set():
+ license_status = self.license_service.verify()
+ if not license_status.valid:
+ with self.runtime_state.lock:
+ self.runtime_state.status_nodes_web = {"SISTEMA": "BLOQUEADO"}
+ self.runtime_state.dados_tempo_real = {}
+ time.sleep(RETRY_INTERVAL_SECONDS)
+ continue
+
+ self.runtime_state.consume_restart()
+ config, device_map, _, _ = self.runtime_state.snapshot()
+ hostname = config.get("hostname_zabbix", "").strip()
+ sender = self._build_sender(config.get("zabbix_server", "").strip())
+
+ self._send_discovery(sender, hostname, config.get("nodes", []), device_map)
+
+ while not self.runtime_state.stop_event.is_set():
+ if self.runtime_state.consume_restart():
+ break
+ self._poll_once(sender, hostname)
+ time.sleep(POLL_INTERVAL_SECONDS)
+
+ def _build_sender(self, server: str):
+ if not server:
+ return None
+ try:
+ return Sender(server=server)
+ except Exception as exc:
+ LOGGER.warning("Nao foi possivel iniciar sender do Zabbix: %s", exc)
+ return None
+
+ def _send_discovery(self, sender, hostname: str, nodes: List[dict], device_map: dict) -> None:
+ if not sender or not hostname or not device_map:
+ return
+
+ lld = {"data": []}
+ for info in device_map.values():
+ lld["data"].append({"{#PONTO_LABEL}": info["label"]})
+ for node in nodes:
+ lld["data"].append({"{#NODE_NOME}": node["nome"]})
+
+ try:
+ sender.send([ItemValue(hostname, "notifier.discovery", json.dumps(lld, ensure_ascii=False))])
+ except Exception as exc:
+ LOGGER.warning("Falha ao enviar discovery ao Zabbix: %s", exc)
+
+ def _poll_once(self, sender, hostname: str) -> None:
+ config, device_map, _, _ = self.runtime_state.snapshot()
+ nodes = config.get("nodes", [])
+ metrics: List[ItemValue] = []
+ latest_data: Dict[str, dict] = {}
+ node_statuses: Dict[str, str] = {}
+
+ for node in nodes:
+ node_metrics, node_data, node_status = self._poll_node(node, device_map, hostname)
+ metrics.extend(node_metrics)
+ latest_data.update(node_data)
+ node_statuses[node["nome"]] = node_status
+
+ with self.runtime_state.lock:
+ self.runtime_state.dados_tempo_real.update(latest_data)
+ self.runtime_state.status_nodes_web = node_statuses
+
+ if sender and hostname and metrics:
+ try:
+ sender.send(metrics)
+ except Exception as exc:
+ LOGGER.warning("Falha ao enviar metricas ao Zabbix: %s", exc)
+
+ def _poll_node(self, node: dict, device_map: dict, hostname: str):
+ client = ModbusClient(
+ host=node["ip"],
+ port=502,
+ unit_id=node["unit"],
+ auto_open=True,
+ timeout=1,
+ )
+ node_metrics: List[ItemValue] = []
+ node_data: Dict[str, dict] = {}
+ node_status = "Offline"
+
+ try:
+ alive = client.read_holding_registers(1, 1)
+ status_conn = 1 if alive is not None else 0
+ node_status = "Online" if status_conn else "Offline"
+
+ if hostname:
+ node_metrics.append(ItemValue(hostname, f"node.status[{node['nome']}]", status_conn))
+
+ if not status_conn:
+ return node_metrics, node_data, node_status
+
+ node_keys = [
+ key for key in device_map.keys() if key.startswith(f"{node['ip']}_{node['unit']}_")
+ ]
+ node_addresses = [int(key.rsplit("_", 1)[-1]) for key in node_keys]
+
+ for start, end in contiguous_ranges(node_addresses):
+ registers = client.read_holding_registers(start, end - start + 1)
+ if not registers:
+ continue
+ for offset, value in enumerate(registers):
+ address = start + offset
+ key = f"{node['ip']}_{node['unit']}_{address}"
+ if key not in device_map:
+ continue
+ label = device_map[key]["label"]
+ node_data[key] = {
+ "label": label,
+ "status": value,
+ "node": node["nome"],
+ "last": time.strftime("%H:%M:%S"),
+ }
+ if hostname:
+ node_metrics.append(ItemValue(hostname, f"notifier.status[{label}]", value & 0xFF))
+ node_metrics.append(ItemValue(hostname, f"notifier.comm[{label}]", (value >> 8) & 0xFF))
+ except Exception as exc:
+ LOGGER.warning("Falha ao consultar node %s (%s): %s", node["nome"], node["ip"], exc)
+ finally:
+ client.close()
+
+ return node_metrics, node_data, node_status
diff --git a/vfire_monitor/routes.py b/vfire_monitor/routes.py
new file mode 100644
index 0000000..d154d05
--- /dev/null
+++ b/vfire_monitor/routes.py
@@ -0,0 +1,150 @@
+from flask import current_app, flash, jsonify, redirect, render_template, request, url_for
+from flask_login import current_user, login_required, login_user, logout_user
+
+from .core import LicenseService, RuntimeState, User, discover_node, get_status_info, validate_node_payload
+
+
+def get_runtime_state() -> RuntimeState:
+ return current_app.extensions["runtime_state"]
+
+
+def get_license_service() -> LicenseService:
+ return current_app.extensions["license_service"]
+
+
+def json_error(message: str, status_code: int = 400):
+ return jsonify({"status": "error", "message": message}), status_code
+
+
+def register_routes(app):
+ @app.route("/login", methods=["GET", "POST"])
+ def login():
+ if current_user.is_authenticated:
+ return redirect(url_for("index"))
+
+ if request.method == "POST":
+ username = request.form.get("username", "").strip()
+ password = request.form.get("password", "")
+ if get_runtime_state().authenticate(username, password):
+ login_user(User(username))
+ return redirect(url_for("index"))
+ flash("Usuario ou senha invalidos.")
+ return render_template("login.html")
+
+ @app.route("/")
+ @login_required
+ def index():
+ config, _, _, _ = get_runtime_state().snapshot()
+ license_status = get_license_service().verify()
+ return render_template(
+ "index.html",
+ config=config,
+ licenciado=license_status.valid,
+ lic_msg=license_status.message,
+ hwid=license_status.hardware_id,
+ dias_restantes=license_status.days_remaining,
+ )
+
+ @app.route("/api/nodes", methods=["POST"])
+ @login_required
+ def api_nodes():
+ payload = request.get_json(silent=True)
+ if not isinstance(payload, dict):
+ return json_error("JSON invalido.")
+
+ try:
+ zabbix_server = str(payload.get("zabbix_server", "")).strip()
+ hostname_zabbix = str(payload.get("hostname_zabbix", "")).strip()
+ nodes = [validate_node_payload(node) for node in payload.get("nodes", [])]
+ except ValueError as exc:
+ return json_error(str(exc))
+
+ if not zabbix_server:
+ return json_error("Zabbix Server e obrigatorio.")
+ if not hostname_zabbix:
+ return json_error("Hostname Zabbix e obrigatorio.")
+
+ get_runtime_state().update_config(zabbix_server, hostname_zabbix, nodes)
+ return jsonify({"status": "success"})
+
+ @app.route("/api/rename", methods=["POST"])
+ @login_required
+ def api_rename():
+ payload = request.get_json(silent=True)
+ if not isinstance(payload, dict):
+ return json_error("JSON invalido.")
+
+ address_key = str(payload.get("addr", "")).strip()
+ label = str(payload.get("label", "")).strip()
+
+ if not address_key:
+ return json_error("Endereco do dispositivo e obrigatorio.")
+ if not label:
+ return json_error("Nome do dispositivo e obrigatorio.")
+
+ if not get_runtime_state().rename_device(address_key, label):
+ return json_error("Dispositivo nao encontrado.", 404)
+
+ return jsonify({"status": "success"})
+
+ @app.route("/api/license", methods=["POST"])
+ def api_license():
+ payload = request.get_json(silent=True)
+ if not isinstance(payload, dict):
+ return json_error("JSON invalido.")
+
+ try:
+ get_license_service().save(str(payload.get("serial", "")))
+ except ValueError as exc:
+ return json_error(str(exc))
+
+ get_runtime_state().trigger_restart()
+ return jsonify({"status": "success"})
+
+ @app.route("/api/rescan_node", methods=["POST"])
+ @login_required
+ def api_rescan():
+ payload = request.get_json(silent=True)
+ if not isinstance(payload, dict):
+ return json_error("JSON invalido.")
+
+ try:
+ node = validate_node_payload(payload)
+ except ValueError as exc:
+ return json_error(str(exc))
+
+ _, current_map, _, _ = get_runtime_state().snapshot()
+ discovered_map = discover_node(node, current_map)
+ get_runtime_state().replace_node_devices(node, discovered_map)
+ return jsonify({"status": "success", "count": len(discovered_map)})
+
+ @app.route("/api/data")
+ @login_required
+ def api_data():
+ _, device_map, realtime_data, node_statuses = get_runtime_state().snapshot()
+ sensors = []
+
+ for key, info in device_map.items():
+ data = realtime_data.get(
+ key,
+ {"status": 0, "last": "--:--:--", "node": info.get("node_nome", "...")},
+ )
+ status_info = get_status_info(int(data["status"]))
+ sensors.append(
+ {
+ "key": key,
+ "label": info["label"],
+ "node": data.get("node", "..."),
+ "status_text": status_info["label"],
+ "color": status_info["color"],
+ "last": data["last"],
+ }
+ )
+
+ return jsonify({"sensores": sensors, "nodes_status": node_statuses})
+
+ @app.route("/logout")
+ @login_required
+ def logout():
+ logout_user()
+ return redirect(url_for("login"))