ajustes saude ipb e stream de video

This commit is contained in:
Diego Freitas 2026-06-16 10:07:00 -03:00
parent 769cf2a4d5
commit 0dfa272660
2 changed files with 964 additions and 161 deletions

View File

@ -1,155 +1,836 @@
import socket
import struct
import threading
import time
import cv2
import numpy as np
from shared.enums import T_Code
from shared.utils import resize_frame
from shared.contexto_global_redis import ContextoGlobalRedis
class CameraTcpStreamer:
def __init__(self, porta: int, mx_id: str, mostrar_log=None):
self.mx_id = mx_id
self.mostrar_log = mostrar_log or (lambda msg: None)
self._sock = None
# ---------------------------------------------------------
# SOCKET TCP
# ---------------------------------------------------------
self.sock_port = porta
self._sock = None
self._sock_conectado = False
self._sock_lock = threading.RLock()
self._sock_ultima_tentativa_conexao = 0.0
self._sock_intervalo_reconexao = 1.0
self._last_frame_sent_ts = 0.0
self._last_mode_change_ts = 0.0
self._sock_timeout_conexao_s = 1.0
self._sock_timeout_envio_s = 0.75
# ---------------------------------------------------------
# CONTROLE ADAPTATIVO
# ---------------------------------------------------------
agora_mono = time.monotonic()
self._op_mode = 1
self._op_fps = 2.0
def fechar(self):
self._sock_conectado = False
if self._sock is not None:
try:
self._sock.close()
except:
pass
# Recupera qualidade lentamente.
self._last_mode_up_ts = agora_mono
self._mode_up_interval_s = 6.0
# Reduz qualidade rapidamente.
self._last_mode_down_ts = 0.0
self._mode_down_interval_s = 1.0
# ---------------------------------------------------------
# MÉTRICAS DO STREAMING
# ---------------------------------------------------------
self._last_frame_sent_mono = 0.0
self._stream_kbps_ema = 0.0
self._streaming_ativo = False
# ---------------------------------------------------------
# CONTROLE DE LOG
# ---------------------------------------------------------
self._log_intervalo_s = 5.0
self._last_log_ts = {}
# =============================================================
# LOG
# =============================================================
def _log(self, msg: str):
try:
self.mostrar_log(msg)
except Exception:
pass
def _log_throttled(self, chave: str, msg: str):
"""
Evita inundar os logs quando uma falha persiste.
"""
agora = time.monotonic()
ultimo = self._last_log_ts.get(chave, 0.0)
if (agora - ultimo) >= self._log_intervalo_s:
self._last_log_ts[chave] = agora
self._log(msg)
# =============================================================
# CONTEXTO REDIS
# =============================================================
def _publicar_estado(self, **campos):
"""
Publicação de telemetria não pode derrubar o streaming caso
ocorra alguma falha temporária no contexto Redis.
"""
try:
ContextoGlobalRedis.atualizar_ctx_dict(
ContextoGlobalRedis.CamKey(self.mx_id),
**campos,
)
except Exception as e:
self._log_throttled(
"redis_publish",
(
f"[{self.mx_id}] Falha ao publicar estado "
f"do streaming: {e}"
),
)
# =============================================================
# SOCKET
# =============================================================
def _fechar_socket(self):
"""
Fecha somente o socket, sem publicar estado.
Pode ser usado durante reconexões internas.
"""
with self._sock_lock:
sock = self._sock
self._sock = None
self._sock_conectado = False
if sock is not None:
try:
sock.shutdown(socket.SHUT_RDWR)
except Exception:
pass
try:
sock.close()
except Exception:
pass
def fechar(self):
"""
Encerramento público do streamer.
"""
self._fechar_socket()
self._stream_kbps_ema = 0.0
self._last_frame_sent_mono = 0.0
self._streaming_ativo = False
self._publicar_estado(
stream_active=False,
stream_connected=False,
stream_kbps=0.0,
stream_fps=0.0,
)
def _sock_tentar_conectar(self):
_cfg = ContextoGlobalRedis.get_equipamento() or {}
_ip = _cfg.get("base_ip")
try:
cfg = ContextoGlobalRedis.get_equipamento() or {}
ip_base = cfg.get("base_ip")
if not _ip or not self.sock_port:
except Exception as e:
self._log_throttled(
"base_config_error",
f"[{self.mx_id}] Falha ao obter IP da base: {e}",
)
return
agora = time.time()
if (agora - self._sock_ultima_tentativa_conexao) < self._sock_intervalo_reconexao:
if not ip_base or not self.sock_port:
return
agora = time.monotonic()
if (
agora - self._sock_ultima_tentativa_conexao
) < self._sock_intervalo_reconexao:
return
self._sock_ultima_tentativa_conexao = agora
self.fechar()
# Remove qualquer socket anterior antes de reconectar.
self._fechar_socket()
novo_socket = None
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
s.settimeout(1.0)
s.connect((_ip, self.sock_port))
s.settimeout(None)
novo_socket = socket.socket(
socket.AF_INET,
socket.SOCK_STREAM,
)
novo_socket.setsockopt(
socket.IPPROTO_TCP,
socket.TCP_NODELAY,
1,
)
novo_socket.setsockopt(
socket.SOL_SOCKET,
socket.SO_KEEPALIVE,
1,
)
# Timeout usado somente durante a conexão.
novo_socket.settimeout(
self._sock_timeout_conexao_s
)
novo_socket.connect(
(ip_base, self.sock_port)
)
# Timeout mantido para impedir sendall bloqueado
# indefinidamente em caso de enlace congestionado.
novo_socket.settimeout(
self._sock_timeout_envio_s
)
with self._sock_lock:
self._sock = novo_socket
self._sock_conectado = True
self._publicar_estado(
stream_connected=True,
stream_last_error=None,
)
self._log(
f"[{self.mx_id}] Socket de vídeo conectado."
)
except Exception as e:
if novo_socket is not None:
try:
novo_socket.close()
except Exception:
pass
with self._sock_lock:
self._sock = None
self._sock_conectado = False
self._publicar_estado(
stream_connected=False,
stream_kbps=0.0,
stream_fps=0.0,
stream_last_error=str(e),
)
self._log_throttled(
"connect_error",
(
f"[{self.mx_id}] Falha ao conectar "
f"socket de vídeo: {e}"
),
)
# =============================================================
# PRESSÃO DO ENLACE
# =============================================================
def _get_ipb_bw_pressure(self):
"""
Retorna a pressão atual do enlace entre 0 e 100.
None significa que ainda não existem dados confiáveis
suficientes para alterar o modo atual.
"""
try:
ipb = (
ContextoGlobalRedis.get_modulo(T_Code.Ipb)
or {}
)
saude = ipb.get("saude") or {}
detalhes = saude.get("detalhes") or {}
self._sock = s
self._sock_conectado = True
self.mostrar_log(f"[{self.mx_id}] Socket de vídeo conectado.")
except Exception:
self._sock_conectado = False
self._sock = None
return None
def _get_ipb_bw_util_pct(self) -> float:
ipb = ContextoGlobalRedis.get_modulo(T_Code.Ipb) or {}
saude = (ipb.get("saude") or {})
detalhes = (saude.get("detalhes") or {})
return float(detalhes.get("bw_pressure_pct") or detalhes.get("bw_util_pct") or 0.0)
# IPB ainda não publicou saúde.
if not saude:
return None
conectado = bool(
saude.get("conectado", False)
)
valor = detalhes.get("bw_pressure_pct")
if valor is None:
valor = detalhes.get("bw_util_pct")
# O IPB publicou estado, mas está desconectado.
# Força comportamento conservador.
if valor is None:
return 90.0 if not conectado else None
try:
pressure = float(valor)
except (TypeError, ValueError):
return 90.0 if not conectado else None
pressure = max(
0.0,
min(100.0, pressure),
)
if not conectado:
pressure = max(pressure, 90.0)
return pressure
# =============================================================
# MODOS DE STREAMING
# =============================================================
def _mode_params(self, mode: int):
table = {
3: dict(fps=5.0, q=55, w=640, h=360),
2: dict(fps=3.0, q=45, w=640, h=360),
1: dict(fps=2.0, q=40, w=480, h=270),
0: dict(fps=1.0, q=35, w=320, h=180),
"""
Os limites são máximos. A resolução real pode ser menor,
pois resize_frame preserva a proporção original.
"""
tabela = {
3: {
"fps": 5.0,
"q": 55,
"w": 640,
"h": 360,
},
2: {
"fps": 3.0,
"q": 45,
"w": 640,
"h": 360,
},
1: {
"fps": 2.0,
"q": 40,
"w": 480,
"h": 270,
},
0: {
"fps": 1.0,
"q": 35,
"w": 320,
"h": 180,
},
}
return table.get(int(mode), table[1])
return tabela.get(
int(mode),
tabela[1],
)
def _maybe_update_mode(self):
util = self._get_ipb_bw_util_pct()
now = time.time()
pressure = self._get_ipb_bw_pressure()
agora = time.monotonic()
if (now - self._last_mode_change_ts) < 1.5:
if pressure is None:
return None
# ---------------------------------------------------------
# EMERGÊNCIA
# Pressão superior a 85 pode reduzir até dois níveis.
# ---------------------------------------------------------
if pressure > 85.0:
if (
self._op_mode > 0
and (
agora - self._last_mode_down_ts
) >= self._mode_down_interval_s
):
self._op_mode = max(
0,
self._op_mode - 2,
)
self._last_mode_down_ts = agora
return pressure
# ---------------------------------------------------------
# PRESSÃO ALTA
# Reduz um nível rapidamente.
# ---------------------------------------------------------
if pressure > 75.0:
if (
self._op_mode > 0
and (
agora - self._last_mode_down_ts
) >= self._mode_down_interval_s
):
self._op_mode -= 1
self._last_mode_down_ts = agora
return pressure
# ---------------------------------------------------------
# ZONA NEUTRA
# Entre 50 e 75 não aumenta nem reduz.
# ---------------------------------------------------------
if pressure >= 50.0:
return pressure
# ---------------------------------------------------------
# ENLACE FOLGADO
# Recupera somente um nível por vez e lentamente.
# ---------------------------------------------------------
if (
self._op_mode < 3
and (
agora - self._last_mode_up_ts
) >= self._mode_up_interval_s
):
self._op_mode += 1
self._last_mode_up_ts = agora
return pressure
# =============================================================
# ESTADO INATIVO
# =============================================================
def _set_streaming_inativo(self):
"""
Executa a limpeza apenas na transição ligado -> desligado.
"""
if not self._streaming_ativo:
return
if util < 55 and self._op_mode < 3:
self._op_mode += 1
self._last_mode_change_ts = now
elif util > 75 and self._op_mode > 0:
self._op_mode -= 1
self._last_mode_change_ts = now
self._streaming_ativo = False
if util > 85 and self._op_mode > 0:
self._op_mode = max(0, self._op_mode - 1)
self._last_mode_change_ts = now
self._fechar_socket()
self._stream_kbps_ema = 0.0
self._last_frame_sent_mono = 0.0
self._publicar_estado(
stream_active=False,
stream_connected=False,
stream_kbps=0.0,
stream_fps=0.0,
)
# =============================================================
# ENVIO
# =============================================================
def enviar_frame_tcp(self, frame_bgr):
_camera = ContextoGlobalRedis.get_camera(self.mx_id) or {}
_stream_on = _camera.get("streaming", False)
if not _stream_on:
return
self._maybe_update_mode()
params = self._mode_params(self._op_mode)
self._op_fps = params["fps"]
now = time.time()
min_dt = 1.0 / max(0.1, self._op_fps)
if (now - self._last_frame_sent_ts) < min_dt:
return
prev_sent_ts = self._last_frame_sent_ts
self._last_frame_sent_ts = now
if not self._sock_conectado or self._sock is None:
self._sock_tentar_conectar()
if not self._sock_conectado or self._sock is None:
return
if frame_bgr is None:
frame_bgr = np.zeros((params["h"], params["w"], 3), dtype=np.uint8)
else:
frame_bgr = resize_frame(frame_bgr, max_width=params["w"], max_height=params["h"])
encode_param = [int(cv2.IMWRITE_JPEG_QUALITY), int(params["q"])]
ok, buf = cv2.imencode(".jpg", frame_bgr, encode_param)
if not ok:
return
data = buf.tobytes()
size = len(data)
header = struct.pack("!I", size)
dt_real = max(0.001, now - prev_sent_ts)
fps_real = 1.0 / dt_real
kbps_inst = (size * 8) / dt_real / 1000.0
kbps_ant = float(_camera.get("stream_kbps", 0.0) or 0.0)
kbps_suave = kbps_inst if kbps_ant <= 0 else (kbps_ant * 0.8 + kbps_inst * 0.2)
# ---------------------------------------------------------
# CONFIGURAÇÃO DA CÂMERA
# ---------------------------------------------------------
try:
self._sock.sendall(header + data)
except (BrokenPipeError, ConnectionResetError, OSError):
self._sock_conectado = False
kbps_suave = 0.0
self.fechar()
finally:
ContextoGlobalRedis.atualizar_ctx_dict(
ContextoGlobalRedis.CamKey(self.mx_id),
stream_last_frame=time.time(),
stream_kbps=round(kbps_suave, 1),
fps=round(fps_real, 1),
stream_w=params["w"],
stream_h=params["h"],
camera = (
ContextoGlobalRedis.get_camera(self.mx_id)
or {}
)
except Exception as e:
self._log_throttled(
"camera_context_error",
(
f"[{self.mx_id}] Falha ao obter "
f"contexto da câmera: {e}"
),
)
return
stream_on = bool(
camera.get("streaming", False)
)
if not stream_on:
self._set_streaming_inativo()
return
if not self._streaming_ativo:
self._streaming_ativo = True
self._publicar_estado(
stream_active=True
)
# ---------------------------------------------------------
# MODO ADAPTATIVO
# ---------------------------------------------------------
pressure = self._maybe_update_mode()
params = self._mode_params(
self._op_mode
)
self._op_fps = float(
params["fps"]
)
# ---------------------------------------------------------
# LIMITADOR DE FPS
# ---------------------------------------------------------
agora_mono = time.monotonic()
min_dt = 1.0 / max(
0.1,
self._op_fps,
)
if (
self._last_frame_sent_mono > 0.0
and (
agora_mono
- self._last_frame_sent_mono
) < min_dt
):
return
# ---------------------------------------------------------
# CONEXÃO
# ---------------------------------------------------------
with self._sock_lock:
conectado = (
self._sock_conectado
and self._sock is not None
)
if not conectado:
self._sock_tentar_conectar()
with self._sock_lock:
conectado = (
self._sock_conectado
and self._sock is not None
)
if not conectado:
return
# ---------------------------------------------------------
# VALIDAÇÃO DO FRAME
# ---------------------------------------------------------
frame_valido = (
frame_bgr is not None
and hasattr(frame_bgr, "shape")
and hasattr(frame_bgr, "size")
and frame_bgr.size > 0
and len(frame_bgr.shape) >= 2
)
if not frame_valido:
self._publicar_estado(
stream_frame_valido=False,
stream_last_error=(
"Frame ausente ou inválido."
),
)
return
# ---------------------------------------------------------
# REDIMENSIONAMENTO
# ---------------------------------------------------------
try:
frame_redimensionado = resize_frame(
frame_bgr,
max_width=int(params["w"]),
max_height=int(params["h"]),
)
except Exception as e:
self._publicar_estado(
stream_frame_valido=False,
stream_last_error=(
f"Falha ao redimensionar frame: {e}"
),
)
self._log_throttled(
"resize_error",
(
f"[{self.mx_id}] Falha ao "
f"redimensionar frame: {e}"
),
)
return
frame_redimensionado_valido = (
frame_redimensionado is not None
and hasattr(
frame_redimensionado,
"shape",
)
and hasattr(
frame_redimensionado,
"size",
)
and frame_redimensionado.size > 0
and len(
frame_redimensionado.shape
) >= 2
)
if not frame_redimensionado_valido:
self._publicar_estado(
stream_frame_valido=False,
stream_last_error=(
"Frame inválido após redimensionamento."
),
)
return
stream_h_real, stream_w_real = (
frame_redimensionado.shape[:2]
)
# ---------------------------------------------------------
# JPEG
# ---------------------------------------------------------
encode_inicio = time.monotonic()
encode_erro = None
try:
encode_param = [
int(cv2.IMWRITE_JPEG_QUALITY),
int(params["q"]),
]
ok, buffer_jpeg = cv2.imencode(
".jpg",
frame_redimensionado,
encode_param,
)
except Exception as e:
ok = False
buffer_jpeg = None
encode_erro = e
encode_ms = (
time.monotonic() - encode_inicio
) * 1000.0
if not ok or buffer_jpeg is None:
erro_txt = (
str(encode_erro)
if encode_erro is not None
else "cv2.imencode retornou falha."
)
self._publicar_estado(
stream_frame_valido=False,
stream_last_error=(
f"Falha ao codificar JPEG: {erro_txt}"
),
stream_encode_ms=round(
encode_ms,
1,
),
)
self._log_throttled(
"encode_error",
(
f"[{self.mx_id}] Falha ao "
f"codificar JPEG: {erro_txt}"
),
)
return
data = buffer_jpeg.tobytes()
size = len(data)
# Cabeçalho de quatro bytes em big-endian.
header = struct.pack(
"!I",
size,
)
# ---------------------------------------------------------
# ENVIO TCP
# ---------------------------------------------------------
send_inicio = time.monotonic()
try:
with self._sock_lock:
if (
not self._sock_conectado
or self._sock is None
):
return
self._sock.sendall(
header + data
)
except (
BrokenPipeError,
ConnectionResetError,
socket.timeout,
OSError,
) as e:
send_ms = (
time.monotonic()
- send_inicio
) * 1000.0
self._fechar_socket()
self._stream_kbps_ema = 0.0
# O frame era válido. O que falhou foi o transporte.
self._publicar_estado(
stream_connected=False,
stream_kbps=0.0,
stream_fps=0.0,
stream_frame_valido=True,
stream_send_ms=round(
send_ms,
1,
),
stream_last_error=str(e),
)
self._log_throttled(
"send_error",
(
f"[{self.mx_id}] Falha ao "
f"enviar frame TCP: {e}"
),
)
return
# ---------------------------------------------------------
# ENVIO CONCLUÍDO
# ---------------------------------------------------------
enviado_mono = time.monotonic()
send_ms = (
enviado_mono - send_inicio
) * 1000.0
if self._last_frame_sent_mono > 0.0:
dt_real = max(
0.001,
enviado_mono
- self._last_frame_sent_mono,
)
else:
# Estimativa inicial para o primeiro frame.
dt_real = 1.0 / max(
0.1,
self._op_fps,
)
self._last_frame_sent_mono = enviado_mono
fps_real = 1.0 / dt_real
kbps_inst = (
size * 8.0
) / dt_real / 1000.0
# EMA para evitar métrica tremendo a cada JPEG.
if self._stream_kbps_ema <= 0.0:
self._stream_kbps_ema = kbps_inst
else:
self._stream_kbps_ema = (
self._stream_kbps_ema * 0.8
+ kbps_inst * 0.2
)
# ---------------------------------------------------------
# PUBLICAÇÃO DAS MÉTRICAS
# ---------------------------------------------------------
self._publicar_estado(
stream_active=True,
stream_connected=True,
stream_frame_valido=True,
# Timestamp de parede somente após envio confirmado.
stream_last_frame=time.time(),
stream_kbps=round(
self._stream_kbps_ema,
1,
),
stream_fps=round(
fps_real,
1,
),
stream_target_fps=round(
self._op_fps,
1,
),
# Dimensões realmente transmitidas.
stream_w=int(
stream_w_real
),
stream_h=int(
stream_h_real
),
stream_mode=int(
self._op_mode
),
stream_quality=int(
params["q"]
),
stream_pressure_pct=(
round(pressure, 1)
if pressure is not None
else None
),
stream_jpeg_bytes=int(
size
),
stream_encode_ms=round(
encode_ms,
1,
),
stream_send_ms=round(
send_ms,
1,
),
stream_last_error=None,
)

View File

@ -86,9 +86,13 @@ class ModuloIPBribge(ModuloDiagnosticoBase):
self._hb_last_rx_monotonic = 0.0
self._last_ping_sample_applied_ts = 0.0
self.bw_max_mbps = 10.0 # capacidade aproximada do link (ajuste por teste)
self.bw_safe_mbps = 05.0 # alvo saudável (até aqui não penaliza)
self.bw_hard_mbps = 2.5 # acima disso é zona vermelha
self._bw_nominal_max_mbps = 10.0
self._bw_nominal_safe_mbps = 6.0
self._bw_nominal_hard_mbps = 7.8
self.bw_max_mbps = self._bw_nominal_max_mbps
self.bw_safe_mbps = self._bw_nominal_safe_mbps
self.bw_hard_mbps = self._bw_nominal_hard_mbps
self.link_state = "OK" # OK | DEGRADED | CRITICAL
self._cycles_degraded = 0
@ -675,48 +679,118 @@ class ModuloIPBribge(ModuloDiagnosticoBase):
t = (math.log10(x) - math.log10(x0)) / (math.log10(x1) - math.log10(x0))
return y0 + t * (y1 - y0)
def _ajustar_limites_banda_por_distancia(self, distancia_m: float):
def _ajustar_limites_banda_por_distancia(self, distancia_m: float | None):
"""
Ajusta bw_max/safe/hard com base na distância da base.
Curva conservadora para Wi-Fi HaLow 915 MHz em campo.
Ajusta os limites de banda usando a distância da base.
Quando não posição confiável, aplica o perfil nominal do enlace,
sem penalização baseada em distância.
"""
# fallback quando não há posição válida
if distancia_m is None or distancia_m < 0:
self.bw_max_mbps = 10.0
self.bw_safe_mbps = 05.0
self.bw_hard_mbps = 02.5
if (
distancia_m is None
or not math.isfinite(float(distancia_m))
or distancia_m < 0
):
self._aplicar_limites_nominais_banda()
return
# limita a curva
d = max(10.0, min(float(distancia_m), 1200.0))
# 10 m -> 10 Mbps
# 1200 m -> 0.4 Mbps
bw_max = self._interp_log(d, 10.0, 1200.0, 10.0, 0.4)
bw_max = self._interp_log(
d,
10.0,
1200.0,
10.0,
0.4
)
# opcional: um pequeno piso e teto
bw_max = max(0.35, min(bw_max, 10.0))
bw_safe = bw_max * 0.60
bw_hard = bw_max * 0.78
bw_safe = max(0.20, bw_max * 0.60)
bw_hard = max(bw_safe + 0.05, bw_max * 0.78)
# garantias mínimas
bw_safe = max(0.20, bw_safe)
bw_hard = max(bw_safe + 0.05, bw_hard)
self.bw_max_mbps = round(bw_max, 3)
self.bw_max_mbps = round(bw_max, 3)
self.bw_safe_mbps = round(bw_safe, 3)
self.bw_hard_mbps = round(bw_hard, 3)
def _normalizar_status_modulo(self, status):
if status is None:
return None
# Caso tenha vindo como enum
return getattr(status, "value", status)
def _obter_status_gnss(self):
"""
Retorna o status atual do módulo GNSS.
Ajuste T_Code.Gps somente se o membro do enum tiver outro nome
no seu projeto, por exemplo T_Code.Gnss.
"""
modulo_gnss = ContextoGlobalRedis.get_modulo(T_Code.Gps) or {}
saude_gnss = modulo_gnss.get("saude") or {}
status = saude_gnss.get(
"status",
modulo_gnss.get("status")
)
return self._normalizar_status_modulo(status)
def _normalizar_distancia(self, valor):
try:
distancia = float(valor)
except (TypeError, ValueError):
return None
if not math.isfinite(distancia) or distancia < 0:
return None
return distancia
def _posicao_confiavel(self, distancia_base):
status_gnss = self._obter_status_gnss()
status_validos = {
StatusModulo.OPERANTE.value,
StatusModulo.ALERTA.value,
}
confiavel = (
status_gnss in status_validos
and distancia_base is not None
)
return confiavel, status_gnss
def _aplicar_limites_nominais_banda(self):
self.bw_max_mbps = self._bw_nominal_max_mbps
self.bw_safe_mbps = self._bw_nominal_safe_mbps
self.bw_hard_mbps = self._bw_nominal_hard_mbps
def atualizar_saude_interno(self):
#print("atualizando saude IPB...")
try:
m = ContextoGlobalRedis.get_modulo(self.t_code) or {}
distancia_base = m.get("distancia_base", -1)
self._ajustar_limites_banda_por_distancia(distancia_m=distancia_base)
distancia_base_bruta = m.get("distancia_base")
distancia_base = self._normalizar_distancia(distancia_base_bruta)
posicao_confiavel, status_gnss = self._posicao_confiavel(
distancia_base
)
regra_distancia_ativa = (
posicao_confiavel
and distancia_base is not None
)
self._ajustar_limites_banda_por_distancia(
distancia_m=distancia_base if regra_distancia_ativa else None
)
self._check_mqtt_heartbeat()
self._start_mqtt_heartbeat_async()
@ -1124,6 +1198,7 @@ class ModuloIPBribge(ModuloDiagnosticoBase):
self._cycles_recovered = min(self._cycles_recovered, 30)
prev_state = self.link_state
if self._cycles_critical >= 10:
self.link_state = "CRITICAL"
elif self._cycles_degraded >= 14:
@ -1131,33 +1206,71 @@ class ModuloIPBribge(ModuloDiagnosticoBase):
elif self._cycles_recovered >= 24:
self.link_state = "OK"
# entrou em DEGRADED agora
if prev_state != "DEGRADED" and self.link_state == "DEGRADED":
self._distancia_inicio_degradado = distancia_base if distancia_base >= 0 else 0.0
self._max_distancia_desde_degradado = self._distancia_inicio_degradado
# enquanto estiver degradado, atualiza a maior distância observada
if self.link_state == "DEGRADED" and distancia_base is not None and distancia_base >= 0:
self._max_distancia_desde_degradado = max(
self._max_distancia_desde_degradado,
distancia_base
)
# ---------------------------------------------------------
# MARCADORES DE DISTÂNCIA
# Só possuem validade quando o GNSS fornece posição confiável
# ---------------------------------------------------------
# entrou em CRITICAL agora
if prev_state != "CRITICAL" and self.link_state == "CRITICAL":
self._distancia_inicio_critico = distancia_base if distancia_base >= 0 else 0.0
# voltou para OK, limpa marcos
if prev_state != "OK" and self.link_state == "OK":
if not regra_distancia_ativa:
# Sem posição confiável, não podemos associar a degradação
# do enlace a uma distância física.
self._distancia_inicio_degradado = None
self._distancia_inicio_critico = None
self._max_distancia_desde_degradado = 0.0
if self.link_state == "OK" and distancia_base >= 0:
self._max_distancia_ok = max(self._max_distancia_ok, distancia_base)
else:
# O enlace está degradado e temos uma distância válida.
if self.link_state == "DEGRADED":
# Inicializa também se o GNSS voltou enquanto o enlace
# já estava no estado DEGRADED.
if self._distancia_inicio_degradado is None:
self._distancia_inicio_degradado = distancia_base
self._max_distancia_desde_degradado = distancia_base
self._max_distancia_desde_degradado = max(
self._max_distancia_desde_degradado,
distancia_base
)
# Registra onde o enlace entrou ou foi observado como crítico.
if self.link_state == "CRITICAL":
if self._distancia_inicio_critico is None:
self._distancia_inicio_critico = distancia_base
# Ao recuperar o enlace, limpa os marcos da degradação anterior.
if self.link_state == "OK":
if prev_state != "OK":
self._distancia_inicio_degradado = None
self._distancia_inicio_critico = None
self._max_distancia_desde_degradado = 0.0
self._max_distancia_ok = max(
self._max_distancia_ok,
distancia_base
)
# Condição 1: Link degradado persistente
if self.link_state == "DEGRADED":
if regra_distancia_ativa:
complemento_descricao = "Não aumentar distância da base."
acoes_degradacao = [
"Bloquear avanço para longe da base",
"Reduzir vídeo/logs/telemetria não crítica",
"Permitir apenas manter posição ou retornar"
]
else:
complemento_descricao = (
"Posição GNSS indisponível. A degradação está sendo avaliada "
"somente pelas métricas reais do enlace."
)
acoes_degradacao = [
"Reduzir vídeo/logs/telemetria não crítica",
"Priorizar tráfego de controle e heartbeat",
"Verificar a estabilidade do enlace antes de continuar a operação"
]
condicoes.append({
"label": "Enlace degradado persistente",
"valor": round(risk_score, 1),
@ -1165,23 +1278,20 @@ class ModuloIPBribge(ModuloDiagnosticoBase):
"descricao": (
f"Rede degradando de forma persistente. "
f"Loss slow {loss_pct_s:.1f}%, timeout slow {tmout_pct_s:.1f}%, "
f"heartbeat {atraso_s:.1f}s. Não aumentar distância da base."
f"heartbeat {atraso_s:.1f}s. "
f"{complemento_descricao}"
),
"acoes": [
"Bloquear avanço para longe da base",
"Reduzir vídeo/logs/telemetria não crítica",
"Permitir apenas manter posição ou retornar"
]
"acoes": acoes_degradacao
})
# Condição 2: Cerca invisível de rede violada
dist_violando_cerca = (
self.link_state == "DEGRADED" and
self._cycles_degraded >= 15 and
risk_score >= 70 and
self._distancia_inicio_degradado is not None and
distancia_base is not None and
distancia_base >= 0 and
distancia_base > self._distancia_inicio_degradado + 5.0
regra_distancia_ativa
and self.link_state == "DEGRADED"
and self._cycles_degraded >= 15
and risk_score >= 70
and self._distancia_inicio_degradado is not None
and distancia_base is not None
and distancia_base > self._distancia_inicio_degradado + 5.0
)
if dist_violando_cerca:
condicoes.append({
@ -1255,7 +1365,6 @@ class ModuloIPBribge(ModuloDiagnosticoBase):
"saude_individual": saude_individual,
"condicoes_operacionais": condicoes,
"detalhes": {
"d_base": distancia_base,
"ls": ls,
"tmout": tmout,
"los": los,
@ -1290,6 +1399,19 @@ class ModuloIPBribge(ModuloDiagnosticoBase):
"distancia_inicio_degradado": self._distancia_inicio_degradado,
"distancia_inicio_critico": self._distancia_inicio_critico,
"max_distancia_ok": self._max_distancia_ok,
"status_gnss": status_gnss,
"posicao_confiavel": posicao_confiavel,
"regra_distancia_ativa": regra_distancia_ativa,
"d_base": distancia_base if regra_distancia_ativa else -1.0,
"d_base_bruta": distancia_base_bruta,
"perfil_limite_banda": (
"adaptativo_por_distancia"
if regra_distancia_ativa
else "nominal_sem_posicao"
),
}
}