#!/usr/bin/env python3
"""Ponte Meshtastic -> Clima Aberto (dominio publico, CC0). Python 3.9+ e a lib meshtastic.

Le a telemetria do seu no Meshtastic (USB, Wi-Fi ou Bluetooth) e envia para a SUA estacao
no mapa. So saem medidas ambientais/energia: nunca nome, posicao, mensagens ou id do no.

Como usar (Windows, no PowerShell; no Linux/Mac troque "py" por "python3"):
  1. py -m pip install "meshtastic==2.7.11" keyring     (Python 3.10 a 3.14)
  2. Feche o app Meshtastic do celular ligado a este no (um cliente por vez).
  3. Na pasta onde salvou este arquivo (ex.: cd $HOME\\Downloads):
     py ponte.py --estacao SEU-ID --serial COM3
     Na 1a vez pede a chave de edicao (a do cadastro) e oferece guardar no cofre do sistema.
  4. Ver os nos que o radio conhece:   py ponte.py --serial COM3 --listar
  5. Estacao longe, outro no em casa de gateway LoRa:  ... --serial COM5 --no !<id da estacao>
  6. Ver o que seria enviado, sem enviar:  ... --seco -v --uma-vez      Wi-Fi: --tcp 192.168.0.50
"""
import argparse
import collections
import email.utils
import getpass
import http.client
import ipaddress
import json
import logging
import math
import os
import re
import signal
import socket
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from pathlib import Path

VERSAO = "1.0.1"
# Endereco do site desde 11/2026 (Cloudflare Pages). Ponte antiga com o endereco do Netlify (padrao antigo, --api ou
# "api" no --config) segue funcionando: a casca do Netlify repassa /api com 200 (POST nao segue redirecionamento).
# Trocar o endereco nao perde a chave: o cofre e indexado so pela estacao.
API_PADRAO = "https://clima-aberto.pages.dev"
USER_AGENT = "clima-aberto-ponte/" + VERSAO
SERVICO_KEYRING = "clima-aberto-ponte"
MESHTASTIC_RECOMENDADA = "2.7.11"

TIMEOUT_HTTP_S = 90          # cold start do Render free ~50 s
INTERVALO_PADRAO_S = 300     # 1 envio a cada 5 min (o no manda ~1 leitura/min pela USB)
# 1a janela: o firmware manda ambiente, energia e placa em relogios de 60 s INDEPENDENTES (fase qualquer).
# Ela fecha quando ja tem AMBIENTE (e passou o minimo, p/ juntar o resto que chegar junto) ou, sem sensor de
# ambiente, depois de 1 ciclo completo do firmware.
PRIMEIRA_JANELA_S = 15
PRIMEIRA_JANELA_MAX_S = 75
CAMPOS_AMBIENTE = ("temp_c", "umidade_pct", "pressao_hpa")
RECONECTAR_S = 30
SILENCIO_MAX_S = 30 * 60     # sem telemetria da estacao por 30 min -> avisa e reconecta
STATUS_S = 3600
TETO_FILA = 20000
BACKOFF_MIN_S = 15
BACKOFF_MAX_S = 15 * 60
RITMO_MIN_S = 6              # entre 2 POSTs da mesma estacao (fica abaixo de 120/10 min)
REVERIFICAR_PAUSA_S = 30 * 60   # estacao pausada (chave recusada...): confere a chave de novo a cada 30 min
RETENTAR_DISCO_S = 60        # falha ao gravar a fila: tenta de novo (regravando tudo) depois disso
BACKLOG_MAIS_NOVA = 3        # fila acumulada (3+ leituras = 10+ min sem enviar): a mais nova vai primeiro
INSTANTANEO_MAX_IDADE_S = 2 * 3600
JANELA_RAJADA_S = 5          # logo apos conectar chegam pacotes VELHOS da fila do no
PACOTE_VELHO_S = 600
DEFASAGEM_AVISO_S = 300      # relogios do PC e do no diferem mais que isso -> avisa
EPOCA_VALIDA = 1704067200    # 2024-01-01: antes disso o relogio do no nao foi acertado
TOLERANCIA_FUTURO_S = 300
ESPERAR_UMA_VEZ_S = 120      # --uma-vez: o no ligado manda ambiente a cada ~1 min
ESPERAR_UMA_VEZ_GATEWAY_S = 11 * 60   # ... pelo LoRa, a cada ~10 min

log = logging.getLogger("ponte")


class ErroUso(Exception):
    """Configuracao/argumento invalido (mensagem pronta para o usuario)."""


class ErroChave(Exception):
    """Problema para obter a chave (a mensagem NUNCA contem a chave)."""


class ErroFila(Exception):
    pass


class ErroTransporte(Exception):
    """Falha ao falar com o no (mensagem pronta para o usuario)."""


# ============================================================ log sem segredos
class _FiltroSegredos(logging.Filter):
    """Ultima barreira: troca qualquer segredo registrado por *** antes de imprimir."""

    def __init__(self):
        super().__init__()
        self.segredos = set()

    def filter(self, record):
        if not self.segredos:
            return True
        try:
            msg = record.getMessage()
        except Exception:
            return True
        limpo = msg
        for s in self.segredos:
            if s and s in limpo:
                limpo = limpo.replace(s, "***")
        if limpo != msg:
            record.msg, record.args = limpo, None
        return True


FILTRO_SEGREDOS = _FiltroSegredos()


def registrar_segredo(s):
    if s:
        FILTRO_SEGREDOS.segredos.add(s)


class _Console(logging.Handler):
    """Escreve no sys.stdout ATUAL (testes capturam com redirect_stdout); nunca quebra por acento."""

    def emit(self, record):
        try:
            texto = self.format(record) + "\n"
            try:
                sys.stdout.write(texto)
            except UnicodeEncodeError:
                sys.stdout.write(texto.encode("ascii", "replace").decode("ascii"))
            sys.stdout.flush()
        except Exception:
            self.handleError(record)


class _Formato(logging.Formatter):
    PREFIXO = {logging.WARNING: "AVISO: ", logging.ERROR: "ERRO: ", logging.CRITICAL: "ERRO: "}

    def format(self, record):
        hora = time.strftime("%H:%M:%S", time.localtime(record.created))
        msg = record.getMessage()
        if record.name != "ponte" and not record.name.startswith("ponte."):
            msg = "[%s] %s" % (record.name, msg)
        return "%s %s%s" % (hora, self.PREFIXO.get(record.levelno, ""), msg)


def configurar_log(verboso=False):
    raiz = logging.getLogger()
    for h in list(raiz.handlers):
        if isinstance(h, _Console):
            raiz.removeHandler(h)
    h = _Console()
    h.setFormatter(_Formato())
    h.addFilter(FILTRO_SEGREDOS)
    raiz.addHandler(h)
    if raiz.level == logging.NOTSET or raiz.level < logging.WARNING:
        raiz.setLevel(logging.WARNING)
    log.setLevel(logging.DEBUG if verboso else logging.INFO)
    # A lib em DEBUG despeja pacotes inteiros (nomes, posicoes) e alguns erros dela trazem bytes crus.
    # As mensagens da ponte ja explicam as falhas em portugues: a lib so aparece com -v (e nunca em DEBUG).
    logging.getLogger("meshtastic").setLevel(logging.WARNING if verboso else logging.CRITICAL)
    return log


# ============================================================ mapeamento (funcoes puras)
# Limites de plausibilidade (copia dos BOUNDS do servidor, medicoes.py). Valor fora -> o campo
# sai da leitura AQUI, para nao perder o resto (o servidor recusaria a leitura inteira com 422).
LIMITES = {
    "temp_c": (-90, 60), "umidade_pct": (0, 100), "pressao_hpa": (800, 1100),
    "bateria_v": (0, 60), "corrente_ma": (-100000, 100000),
    "vento_kmh": (0, 500), "rajada_kmh": (0, 500), "vento_dir_graus": (0, 360),
    "chuva_mm_1h": (0, 500), "chuva_mm_24h": (0, 2000), "lux": (0, 200000), "iaq": (0, 500),
    "solo_umidade_pct": (0, 100), "solo_temp_c": (-50, 80),
    "pm25": (0, 2000), "pm10": (0, 5000), "co2_ppm": (0, 50000),
    "bateria_pct": (0, 100), "tensao_no_v": (0, 60),
}
CAMPOS = frozenset(LIMITES)   # LISTA BRANCA: nada fora daqui chega a API

# (campo no dict da lib (camelCase, MessageToDict), destino na API, fator)
_MAPA_AMBIENTE = (
    ("temperature", "temp_c", 1.0),             # C (BME280)
    ("relativeHumidity", "umidade_pct", 1.0),   # %
    ("barometricPressure", "pressao_hpa", 1.0), # hPa absoluta da estacao
    ("windSpeed", "vento_kmh", 3.6),            # m/s -> km/h
    ("windGust", "rajada_kmh", 3.6),            # m/s -> km/h
    ("windDirection", "vento_dir_graus", 1.0),  # graus, 0 = Norte
    ("rainfall1h", "chuva_mm_1h", 1.0),
    ("rainfall24h", "chuva_mm_24h", 1.0),
    ("lux", "lux", 1.0),
    ("iaq", "iaq", 1.0),
    ("soilMoisture", "solo_umidade_pct", 1.0),
    ("soilTemperature", "solo_temp_c", 1.0),
)
_MAPA_AR = (("pm25Standard", "pm25", 1.0), ("pm100Standard", "pm10", 1.0))
_SNAKE_ESPECIAL = {"rainfall1h": "rainfall_1h", "rainfall24h": "rainfall_24h"}


def _snake(nome):
    if nome in _SNAKE_ESPECIAL:
        return _SNAKE_ESPECIAL[nome]
    return re.sub(r"([A-Z])", lambda m: "_" + m.group(1).lower(), nome)


def _numero(v):
    """float finito ou None. MessageToDict transforma NaN/inf em STRING ('NaN', 'Infinity')."""
    if v is None or isinstance(v, bool):
        return None
    if isinstance(v, (int, float)):
        f = float(v)
    elif isinstance(v, str):
        try:
            f = float(v.strip())
        except ValueError:
            return None
    else:
        return None
    return f if math.isfinite(f) else None


def _campo(d, nome):
    """Le um campo numerico aceitando camelCase (lib) ou snake_case (proto/JSON de MQTT)."""
    if not isinstance(d, dict):
        return None
    v = d.get(nome)
    if v is None:
        v = d.get(_snake(nome))
    return _numero(v)


def _variante(tel, nome):
    v = tel.get(nome)
    if not isinstance(v, dict):
        v = tel.get(_snake(nome))
    return v if isinstance(v, dict) else None


def mapear_telemetria(tel):
    """dict de telemetria da lib (packet['decoded']['telemetry'], ou um no do nodesByNum)
    -> {campo_da_api: valor}. So le campos numericos conhecidos; ignora todo o resto."""
    if not isinstance(tel, dict):
        return {}
    amb = _variante(tel, "environmentMetrics")
    pot = _variante(tel, "powerMetrics")
    dev = _variante(tel, "deviceMetrics")
    ar = _variante(tel, "airQualityMetrics")
    m = {}
    if amb:
        for campo, destino, fator in _MAPA_AMBIENTE:
            v = _campo(amb, campo)
            if v is not None:
                m[destino] = v * fator
    # Bateria da estacao (12 V): powerMetrics.ch1 > environmentMetrics.voltage (INA219, onde o
    # firmware 2.7 grava) > powerMetrics.ch3 (INA219 lido pelo modulo Power cai no ch3 no 2.7).
    # 0 V = sem leitura (sensor ausente/desligado): nao vira "bateria morta" no mapa.
    for fonte, cv, cc in ((pot, "ch1Voltage", "ch1Current"), (amb, "voltage", "current"),
                          (pot, "ch3Voltage", "ch3Current")):
        v = _campo(fonte, cv) if fonte else None
        if v is not None and v > 0:
            m["bateria_v"] = v
            c = _campo(fonte, cc)
            if c is not None:
                m["corrente_ma"] = c
            break
    if dev:
        nivel = _campo(dev, "batteryLevel")
        if nivel is not None and 0 <= nivel <= 100:   # 101 = alimentado por fonte: nao envia
            m["bateria_pct"] = nivel
        v = _campo(dev, "voltage")
        if v is not None and v > 0:                   # bateria da PLACA, nao os 12 V
            m["tensao_no_v"] = v
    if ar:
        for campo, destino, fator in _MAPA_AR:
            v = _campo(ar, campo)
            if v is not None:
                m[destino] = v * fator
        co2 = _campo(ar, "co2")
        if co2 is not None and co2 > 0:               # 0 ppm nao existe ao ar livre: sem leitura
            m["co2_ppm"] = co2
    return {k: round(v, 2) for k, v in m.items()}


def separar_plausiveis(medidas):
    """(dentro_dos_limites, nomes_fora). Descarta o que nao esta na lista branca."""
    ok, fora = {}, []
    for k, v in (medidas or {}).items():
        if k not in LIMITES:
            continue
        f = _numero(v)
        if f is None:
            continue
        lo, hi = LIMITES[k]
        if lo <= f <= hi:
            ok[k] = f
        else:
            fora.append(k)
    return ok, fora


def iso_utc(t):
    if isinstance(t, datetime):
        dt = t if t.tzinfo else t.replace(tzinfo=timezone.utc)
    else:
        dt = datetime.fromtimestamp(float(t), timezone.utc)
    return dt.astimezone(timezone.utc).isoformat(timespec="seconds")


def montar_leitura(medidas, ts=None):
    """Corpo do POST: {"ts": ISO-UTC, <so campos da lista branca, finitos e plausiveis>}.
    None se nao sobrou nenhuma medida."""
    ok, _ = separar_plausiveis(medidas)
    if not ok:
        return None
    leitura = {}
    if ts is not None:
        leitura["ts"] = iso_utc(ts)
    for k in LIMITES:            # ordem estavel
        if k in ok:
            leitura[k] = ok[k]
    return leitura


def ler_id_no(v):
    """'!433d1a2c', '433d1a2c', '0x433d1a2c' ou decimal (9-10 digitos) -> int."""
    if isinstance(v, int) and not isinstance(v, bool):
        n = v
    else:
        s = str(v or "").strip().lower()
        m = re.fullmatch(r"!?([0-9a-f]{8})", s) or re.fullmatch(r"0x([0-9a-f]{1,8})", s)
        if m:
            n = int(m.group(1), 16)
        elif re.fullmatch(r"\d{9,10}", s):
            n = int(s)
        else:
            raise ErroUso("Id de no invalido: %r. Use o formato !xxxxxxxx (8 hexadecimais, ex.: "
                          "!433d1a2c; sem o ! tambem vale). Veja os ids com --listar." % (v,))
    if not 0 < n < 0xFFFFFFFF:
        raise ErroUso("Id de no fora da faixa: %r." % (v,))
    return n


def id_no(num):
    return "!%08x" % num


def validar_estacao(s):
    s = str(s or "").strip()
    if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,79}", s):
        raise ErroUso("Id de estacao invalido: %r. Use o id (slug) que o site mostra, ex.: vera-cruz "
                      "(letras minusculas, numeros e hifen)." % (s,))
    return s


def _host_local(h):
    # So o nome 'localhost' e IP de loopback. '<algo>.localhost' (ou 'localhost.') NAO: no Windows esse nome
    # vai ao DNS da rede, que pode responder um IP de fora e levar a chave em claro (http) ate ele.
    if h == "localhost":
        return True
    try:
        return ipaddress.ip_address(h).is_loopback
    except ValueError:
        return False


def _ip_privado(h):
    try:
        ip = ipaddress.ip_address(h)
    except ValueError:
        return False
    return ip.is_private or ip.is_link_local


def validar_api(url, permitir_http_local=False):
    """-> (base_normalizada, aviso_ou_None). A chave so trafega sob https (http so p/ localhost,
    ou IP de rede local com --permitir-http-local)."""
    s = (url or "").strip()
    try:
        p = urllib.parse.urlsplit(s)
        porta = p.port
    except ValueError:
        raise ErroUso("--api invalida: %r. Exemplo: %s" % (s, API_PADRAO))
    esquema = (p.scheme or "").lower()
    if esquema not in ("http", "https") or not p.hostname:
        raise ErroUso("--api invalida: %r. Exemplo: %s" % (s, API_PADRAO))
    if p.username or p.password:
        raise ErroUso("--api nao pode ter usuario/senha na URL.")
    if p.query or p.fragment:
        raise ErroUso("--api deve ser so o endereco do site, sem ?parametros ou #fragmento.")
    host = p.hostname.lower()
    caminho = p.path.rstrip("/")
    if caminho.endswith("/api"):
        caminho = caminho[:-4]
    netloc = ("[%s]" % host if ":" in host else host) + (":%d" % porta if porta else "")
    base = "%s://%s%s" % (esquema, netloc, caminho)
    aviso = None
    if esquema == "http" and not _host_local(host):
        if permitir_http_local and _ip_privado(host):
            aviso = ("http:// na rede local (%s): a chave de edicao vai SEM criptografia e qualquer um "
                     "nessa rede pode ve-la. Use so em rede de casa confiavel." % host)
        else:
            raise ErroUso("Recusado: http:// mandaria a chave de edicao sem criptografia. Use https:// "
                          "(ou http://localhost para testes; servidor na rede de casa: "
                          "--permitir-http-local com o IP, ex.: http://192.168.0.10:8080).")
    return base, aviso


def _resumo(leitura):
    partes = []
    for k, fmt in (("temp_c", "{:.1f} C"), ("umidade_pct", "{:.0f} %"), ("pressao_hpa", "{:.1f} hPa"),
                   ("bateria_v", "{:.2f} V")):
        if k in leitura:
            partes.append(fmt.format(leitura[k]))
    outros = [k for k in leitura if k not in ("ts", "temp_c", "umidade_pct", "pressao_hpa", "bateria_v")]
    if outros:
        partes.append("+%d campo(s)" % len(outros))
    return " | ".join(partes) or "(vazia)"


def _duracao(s):
    s = int(round(s))
    if s < 90:
        return "%d s" % s
    if s < 5400:
        return "%d min" % round(s / 60)
    return "%.1f h" % (s / 3600)


def _quando(seg):
    if seg is None:
        return "?"
    if seg < 90:
        return "agora"
    if seg < 5400:
        return "ha %d min" % (seg // 60)
    if seg < 172800:
        return "ha %d h" % (seg // 3600)
    return "ha %d d" % (seg // 86400)


def _ascii(s):
    s = "".join(c if c.isprintable() else "?" for c in str(s))
    return s.encode("ascii", "replace").decode("ascii")


def cmd_python(nome_so=None, versao=None, em_venv=None):
    """Como chamar ESTE Python num terminal, para as instrucoes copiaveis. No Windows 'python' costuma cair no
    atalho da Microsoft Store; o lancador 'py' com a versao certa funciona no PowerShell e no Prompt."""
    nome_so = os.name if nome_so is None else nome_so
    versao = tuple(sys.version_info[:2]) if versao is None else tuple(versao[:2])
    if em_venv is None:
        em_venv = sys.prefix != getattr(sys, "base_prefix", sys.prefix)
    if em_venv:
        return "python"          # ambiente virtual ativado: o 'python' dele
    if nome_so == "nt":
        return "py -%d.%d" % versao
    return "python3"


# ============================================================ fila offline (JSONL)
class _Trava:
    """Trava do arquivo da fila (o SO solta sozinho se o processo morrer)."""

    def __init__(self, caminho):
        self.caminho = caminho + ".trava"
        self.f = None

    def adquirir(self):
        f = open(self.caminho, "a+b")
        try:
            if os.name == "nt":
                import msvcrt
                f.seek(0)
                msvcrt.locking(f.fileno(), msvcrt.LK_NBLCK, 1)
            else:
                import fcntl
                fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
        except OSError:
            f.close()
            return False
        self.f = f
        return True

    def liberar(self):
        f, self.f = self.f, None
        if f is None:
            return
        try:
            if os.name == "nt":
                import msvcrt
                f.seek(0)
                msvcrt.locking(f.fileno(), msvcrt.LK_UNLCK, 1)
        except OSError:
            pass
        f.close()
        try:
            os.remove(self.caminho)
        except OSError:
            pass


def _substituir(origem, destino):
    for tentativa in range(10):   # antivirus/indexador do Windows pode segurar o arquivo um instante
        try:
            os.replace(origem, destino)
            return
        except PermissionError:
            if tentativa == 9:
                raise
            time.sleep(0.1)


class Fila:
    """Fila de leituras a enviar, robusta a queda de energia.

    Arquivo JSONL so de ACRESCIMO (cada linha com fsync): {"seq","estacao","leitura"} = leitura
    pendente; {"ok": seq} = enviada/descartada. Linha cortada no fim (queda de luz) e ignorada.
    Compactacao = reescreve so as pendentes num .tmp e troca atomicamente (os.replace).
    Falha de disco (cheio por um instante, antivirus segurando o arquivo) e TEMPORARIA: segue na memoria e,
    depois de RETENTAR_DISCO_S, regrava tudo a partir da memoria (o que tambem conserta o arquivo velho).
    caminho=None -> so na memoria (modo --seco/testes)."""

    def __init__(self, caminho=None, teto=TETO_FILA, travar=True, monotonico=time.monotonic):
        self.caminho = str(caminho) if caminho else None
        self.teto = max(1, int(teto))
        self._itens = collections.OrderedDict()
        self._seq = 0
        self._linhas = 0
        self._f = None
        self._lock = threading.RLock()
        self._trava = None
        self._mono = monotonico
        self._aviso_cheia = -1e18
        self._aviso_disco = -1e18
        self._falha_disco_em = None   # monotonico da ultima falha de gravacao (None = disco ok)
        self._fechada = False
        if self.caminho:
            pasta = os.path.dirname(os.path.abspath(self.caminho))
            os.makedirs(pasta, exist_ok=True)
            if travar:
                t = _Trava(self.caminho)
                if not t.adquirir():
                    raise ErroFila("Outra ponte ja esta rodando com a fila %s. Feche-a, ou use "
                                   "--fila OUTRO_ARQUIVO." % self.caminho)
                self._trava = t
            self._carregar()
            self._compactar()

    # ---- disco
    def _carregar(self):
        if not os.path.exists(self.caminho):
            return
        ruins = 0
        itens = {}
        with open(self.caminho, "rb") as f:
            for linha in f:
                if not linha.strip():
                    continue
                try:
                    d = json.loads(linha)
                except ValueError:
                    ruins += 1
                    continue
                if isinstance(d, dict) and isinstance(d.get("ok"), int):
                    itens.pop(d["ok"], None)
                elif (isinstance(d, dict) and isinstance(d.get("seq"), int) and isinstance(d.get("estacao"), str)
                      and isinstance(d.get("leitura"), dict)):
                    itens[d["seq"]] = {"seq": d["seq"], "estacao": d["estacao"], "leitura": d["leitura"]}
                    self._seq = max(self._seq, d["seq"])
                else:
                    ruins += 1
        for seq in sorted(itens):
            self._itens[seq] = itens[seq]
        if ruins:
            log.warning("Fila: %d linha(s) corrompida(s) ignorada(s) em %s (provavel queda de energia).",
                        ruins, self.caminho)
        if self._itens:
            log.info("Fila: %d leitura(s) pendente(s) de uma execucao anterior; vou reenviar.", len(self._itens))

    def _erro_disco(self, ex):
        agora = self._mono()
        if agora - self._aviso_disco >= 3600:
            self._aviso_disco = agora
            log.error("Nao consegui gravar a fila em %s (%s). Sigo na memoria e tento gravar de novo a cada %s; se "
                      "a ponte fechar antes disso, as leituras pendentes podem se perder.", self.caminho,
                      type(ex).__name__, _duracao(RETENTAR_DISCO_S))
        self._falha_disco_em = agora

    def _anexar(self, objs):
        if not self.caminho or self._fechada:
            return   # (fechada: um "ok" atrasado da thread de envio no maximo reenvia 1 leitura na proxima vez)
        if self._falha_disco_em is not None:
            # o arquivo ficou para tras (linhas perdidas): so uma regravacao COMPLETA o deixa certo de novo
            if self._mono() - self._falha_disco_em >= RETENTAR_DISCO_S:
                self._compactar()
            return
        try:
            if self._f is None:
                self._f = open(self.caminho, "ab")
            dados = b"".join(json.dumps(o, separators=(",", ":"), ensure_ascii=True, allow_nan=False)
                             .encode("ascii") + b"\n" for o in objs)
            self._f.write(dados)
            self._f.flush()
            os.fsync(self._f.fileno())
            self._linhas += len(objs)
        except (OSError, ValueError) as ex:
            self._erro_disco(ex)
            return
        if self._linhas > 2 * len(self._itens) + 200:
            self._compactar()

    def _compactar(self):
        if not self.caminho:
            return
        try:
            f_antigo, self._f = self._f, None
            if f_antigo is not None:
                try:
                    f_antigo.close()
                except OSError:
                    pass
            tmp = self.caminho + ".tmp"
            with open(tmp, "wb") as f:
                for it in self._itens.values():
                    f.write(json.dumps(it, separators=(",", ":"), ensure_ascii=True, allow_nan=False)
                            .encode("ascii") + b"\n")
                f.flush()
                os.fsync(f.fileno())
            _substituir(tmp, self.caminho)
            self._linhas = len(self._itens)
        except (OSError, ValueError) as ex:
            self._erro_disco(ex)
            return
        if self._falha_disco_em is not None:
            self._falha_disco_em = None
            log.info("Fila voltou a ser gravada em disco (%s).", self.caminho)

    # ---- API
    def adicionar(self, estacao, leitura):
        with self._lock:
            descartados = []
            while len(self._itens) >= self.teto:
                seq, _ = self._itens.popitem(last=False)
                descartados.append(seq)
            if descartados and self._mono() - self._aviso_cheia > 3600:
                self._aviso_cheia = self._mono()
                log.warning("Fila cheia (%d leituras): descartando as mais antigas. A API esta fora ha muito "
                            "tempo ou a chave foi recusada?", self.teto)
            self._seq += 1
            item = {"seq": self._seq, "estacao": estacao, "leitura": dict(leitura)}
            self._itens[self._seq] = item
            self._anexar([{"ok": s, "d": 1} for s in descartados] + [item])
            return self._seq

    def proximo(self, estacao, mais_nova_apos=None):
        """A mais ANTIGA pendente da estacao. Com mais_nova_apos=seq: se ha fila acumulada (BACKLOG_MAIS_NOVA+)
        e a MAIS NOVA ainda nao foi enviada (seq dela > mais_nova_apos), devolve ela primeiro -- depois de uma
        queda longa o mapa volta na hora, e o historico enche em seguida (o servidor ordena por ts). Fila curta
        segue na ordem natural. 'mais_nova' marca qual veio."""
        with self._lock:
            achados = []
            for x in self._itens.values():
                if x["estacao"] == estacao:
                    achados.append(x)
                    if len(achados) >= BACKLOG_MAIS_NOVA:
                        break
            if not achados:
                return None
            primeiro = achados[0]
            ultimo = next(x for x in reversed(self._itens.values()) if x["estacao"] == estacao)
            it, nova = primeiro, primeiro is ultimo
            if (mais_nova_apos is not None and len(achados) >= BACKLOG_MAIS_NOVA
                    and ultimo["seq"] > mais_nova_apos):
                it, nova = ultimo, True
            return {"seq": it["seq"], "estacao": it["estacao"], "leitura": dict(it["leitura"]), "mais_nova": nova}

    def _remover(self, seq, descartada):
        with self._lock:
            if self._itens.pop(seq, None) is not None:
                self._anexar([{"ok": seq, "d": 1} if descartada else {"ok": seq}])

    def confirmar(self, seq):
        self._remover(seq, False)

    def descartar(self, seq):
        self._remover(seq, True)

    def pendentes(self, estacao=None):
        with self._lock:
            return [{"seq": it["seq"], "estacao": it["estacao"], "leitura": dict(it["leitura"])}
                    for it in self._itens.values() if estacao is None or it["estacao"] == estacao]

    def contar(self, estacao=None):
        with self._lock:
            if estacao is None:
                return len(self._itens)
            return sum(1 for it in self._itens.values() if it["estacao"] == estacao)

    def estacoes(self):
        with self._lock:
            return list(dict.fromkeys(it["estacao"] for it in self._itens.values()))

    def __len__(self):
        return self.contar()

    def fechar(self):
        with self._lock:
            if self._fechada:
                return
            self._fechada = True
            self._compactar()
            if self._f is not None:
                try:
                    self._f.close()
                except OSError:
                    pass
                self._f = None
            if self._trava:
                self._trava.liberar()
                self._trava = None


# ============================================================ envio HTTP (urllib)
class _SemRedirecionar(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None   # nunca segue: o header com a chave iria junto para outro endereco


_ABRIDORES = {}


def _abridor(esquema):
    ab = _ABRIDORES.get(esquema)
    if ab is None:
        handlers = [_SemRedirecionar()]
        if esquema == "http":
            # http so existe p/ localhost/rede local (validar_api): NUNCA via proxy (HTTP_PROXY ou o do Windows),
            # senao o pedido sai da maquina e a chave vai em claro ate o proxy. https pode usar proxy: e um
            # tunel (CONNECT) e o TLS e conferido de ponta a ponta.
            handlers.insert(0, urllib.request.ProxyHandler({}))
        ab = _ABRIDORES[esquema] = urllib.request.build_opener(*handlers)
    return ab


def _cabecalhos(h):
    try:
        return {k.lower(): v for k, v in h.items()}
    except Exception:
        return {}


def abrir_url_padrao(req, timeout):
    """-> (status, cabecalhos_minusculos, corpo_texto). Erros de rede sobem como excecao."""
    ab = _abridor(urllib.parse.urlsplit(req.full_url).scheme.lower())
    try:
        with ab.open(req, timeout=timeout) as r:
            return r.status, _cabecalhos(r.headers), r.read(65536).decode("utf-8", "replace")
    except urllib.error.HTTPError as e:
        try:
            corpo = e.read(65536).decode("utf-8", "replace")
        except Exception:
            corpo = ""
        return e.code, _cabecalhos(e.headers), corpo


class _ErroRede(Exception):
    def __init__(self, motivo):
        super().__init__(motivo)
        self.motivo = motivo


def _motivo_rede(e):
    r = e.reason if isinstance(e, urllib.error.URLError) and not isinstance(e, urllib.error.HTTPError) else e
    if isinstance(r, (socket.timeout, TimeoutError)):
        return "tempo esgotado"
    if isinstance(r, ConnectionRefusedError):
        return "conexao recusada"
    if isinstance(r, socket.gaierror):
        return "endereco nao encontrado (sem internet ou DNS)"
    try:
        import ssl
        if isinstance(r, ssl.SSLError):
            return "erro de TLS/certificado (%s)" % (getattr(r, "reason", None) or type(r).__name__)
    except ImportError:
        pass
    return "%s: %s" % (type(r).__name__, str(r)[:120])


def _detalhe(corpo):
    try:
        d = json.loads(corpo)
        det = d.get("detail", d) if isinstance(d, dict) else d
    except (ValueError, TypeError):
        det = corpo or ""
    if not isinstance(det, str):
        det = json.dumps(det, ensure_ascii=True)
    return re.sub(r"\s+", " ", det).strip()[:200]


def _eh_404_da_estacao(det):
    """404 com a mensagem do servidor ("estacao 'x' nao encontrada") = prova forte de que a estacao nao existe.
    404 generico (pagina de proxy, rota inexistente) NAO e."""
    d = (det or "").lower()
    return "estacao" in d and "encontrad" in d


def _hora_servidor(cab):
    """epoch do header Date da resposta (todo servidor HTTP/1.1 manda) ou None."""
    try:
        dt = email.utils.parsedate_to_datetime((cab or {}).get("date"))
        if dt.tzinfo is None:
            dt = dt.replace(tzinfo=timezone.utc)
        return dt.timestamp()
    except Exception:
        return None


def _retry_after(cab, padrao=60):
    v = (cab or {}).get("retry-after")
    if not v:
        return padrao
    try:
        s = float(v)
    except ValueError:
        try:
            s = (email.utils.parsedate_to_datetime(v) - datetime.now(timezone.utc)).total_seconds()
        except Exception:
            return padrao
    return max(1.0, min(float(s), 3600.0))


class Enviador:
    """POST das leituras da fila. A chave vai SO no header X-Chave-Edicao; nunca em URL/log.

    relogio = hora de parede (so para conferir/corrigir o 'ts'); agendamento (ritmo, backoff, nova conferencia
    da chave) no relogio MONOTONICO: acertar a hora do PC nao congela os envios."""

    def __init__(self, api, chaves, abrir_url=None, relogio=time.time, timeout=TIMEOUT_HTTP_S,
                 ritmo_s=RITMO_MIN_S, monotonico=None):
        self.api = api.rstrip("/")
        self._chaves = dict(chaves)
        for c in self._chaves.values():
            registrar_segredo(c)
        self._abrir = abrir_url or abrir_url_padrao
        self._relogio = relogio
        self._mono = monotonico or (time.monotonic if relogio is time.time else relogio)
        self.timeout = timeout
        self.ritmo_s = ritmo_s
        self.pausadas = {}        # estacao -> motivo
        self._proxima = {}        # estacao -> monotonico da proxima tentativa
        self._backoff = {}
        self._reverificar_em = {}  # estacao pausada -> monotonico da proxima conferencia da chave
        self._nova_ok = {}        # estacao -> seq da leitura MAIS NOVA ja entregue (as novas furam a fila)
        self._avisou_sem_chave = set()
        self._aviso_futuro = -1e18
        self.enviadas = 0
        self.descartadas = 0

    def _url(self, estacao, sufixo):
        return "%s/api/estacoes/%s/%s" % (self.api, urllib.parse.quote(estacao, safe=""), sufixo)

    def _chamar(self, estacao, sufixo, corpo):
        dados = b"" if corpo is None else json.dumps(corpo, separators=(",", ":"), allow_nan=False).encode("utf-8")
        req = urllib.request.Request(self._url(estacao, sufixo), data=dados, method="POST", headers={
            "Content-Type": "application/json", "Accept": "application/json",
            "User-Agent": USER_AGENT, "X-Chave-Edicao": self._chaves[estacao]})
        try:
            return self._abrir(req, self.timeout)
        except (urllib.error.URLError, OSError, http.client.HTTPException) as e:
            raise _ErroRede(_motivo_rede(e)) from None

    def verificar_chave(self, estacao):
        """-> (codigo, detalhe). codigo: ok | sem_endpoint | chave_errada | estacao_inexistente |
        rede | https | redirecionamento | inesperado. Nao grava nada no servidor."""
        try:
            st, cab, corpo = self._chamar(estacao, "verificar-chave", None)
        except _ErroRede as e:
            return "rede", e.motivo
        det = _detalhe(corpo)
        if st == 200:
            return "ok", ""
        if st in (401, 403):
            return "chave_errada", det
        if st == 404:
            # 404 da ESTACAO traz "estacao 'x' nao encontrada"; 404 generico = rota que nao existe
            return ("estacao_inexistente" if _eh_404_da_estacao(det) else "sem_endpoint"), det
        if st == 405:   # servidor antigo: o POST cai no site estatico
            return "sem_endpoint", det
        if 300 <= st < 400:
            return "redirecionamento", "HTTP %d" % st
        if st == 400 and "https" in det.lower():
            return "https", det
        if st == 429 or st >= 500:
            return "rede", "HTTP %d" % st
        return "inesperado", "HTTP %d: %s" % (st, det)

    def bloqueada(self, estacao, folga_s=0.0):
        return estacao in self.pausadas or self._proxima.get(estacao, 0) > self._mono() + folga_s

    def processar(self, fila, max_envios=50):
        """Envia o que estiver na hora. Devolve quantos POSTs fez."""
        n = 0
        for est in fila.estacoes():
            if est not in self._chaves:
                if est not in self._avisou_sem_chave:
                    self._avisou_sem_chave.add(est)
                    log.info("Fila tem %d leitura(s) da estacao '%s', que nao esta configurada agora; "
                             "ficam guardadas.", fila.contar(est), est)
                continue
            if est in self.pausadas and not self._reverificar(est, fila):
                continue
            while n < max_envios:
                if est in self.pausadas or self._mono() < self._proxima.get(est, 0):
                    break
                item = fila.proximo(est, mais_nova_apos=self._nova_ok.get(est, 0))
                if item is None:
                    break
                r = self._enviar_item(fila, est, item)
                n += 1
                if r in ("ok", "descartada") and item.get("mais_nova"):
                    self._nova_ok[est] = max(self._nova_ok.get(est, 0), item["seq"])
                if r not in ("ok", "descartada"):
                    break
        return n

    def _reverificar(self, est, fila):
        """Estacao pausada: de tempos em tempos confere a chave de novo e DESPAUSA sozinha se voltou a valer
        (estacao 24/7 sem ninguem olhando nao fica parada para sempre). -> True se despausou."""
        agora = self._mono()
        if agora < self._reverificar_em.get(est, 0):
            return False
        self._reverificar_em[est] = agora + REVERIFICAR_PAUSA_S
        cod, _ = self.verificar_chave(est)
        if cod not in ("ok", "sem_endpoint"):
            return False
        self.pausadas.pop(est, None)
        self._backoff.pop(est, None)
        self._proxima.pop(est, None)
        log.info("Estacao '%s' despausada (%s). Voltando a enviar a fila (%d leitura(s)).", est,
                 "a chave confere" if cod == "ok" else "vou tentar enviar de novo", fila.contar(est))
        return True

    def _enviar_item(self, fila, est, item):
        # 2a barreira (cobre tambem um arquivo de fila adulterado): so ts + campos da lista branca
        leitura = {k: v for k, v in item["leitura"].items()
                   if (k == "ts" and isinstance(v, str) and len(v) <= 40) or (k in CAMPOS and _numero(v) is not None)}
        if not any(k in CAMPOS for k in leitura):
            fila.descartar(item["seq"])
            log.warning("Leitura sem nenhuma medida valida na fila; descartada.")
            return "descartada"
        try:
            st, cab, corpo = self._chamar(est, "medicoes", leitura)
            if st == 422 and "futuro" in _detalhe(corpo).lower() and "ts" in leitura:
                leitura, st, cab, corpo = self._reenviar_futuro(est, leitura, cab)
        except _ErroRede as e:
            return self._falha_temporaria(fila, est, "Sem conexao com a API (%s)" % e.motivo)
        agora = self._mono()
        if st in (200, 201):
            fila.confirmar(item["seq"])
            self.enviadas += 1
            self._backoff.pop(est, None)
            self._proxima[est] = agora + self.ritmo_s
            resta = fila.contar(est)
            if resta <= 3 or resta % 50 == 0:
                log.info("Enviado [%s] %s. Fila: %d", est, _resumo(leitura), resta)
            else:
                log.debug("Enviado [%s] %s. Fila: %d", est, _resumo(leitura), resta)
            return "ok"
        det = _detalhe(corpo)
        if st == 422:
            fila.descartar(item["seq"])
            self.descartadas += 1
            log.warning("Leitura recusada pelo servidor (422) e descartada: %s (campos: %s).", det,
                        ", ".join(k for k in leitura if k != "ts"))
            return "descartada"
        if st == 429:
            espera = _retry_after(cab, 60)
            self._proxima[est] = agora + espera
            log.warning("A API pediu calma (429) para '%s': proxima tentativa em %s.", est, _duracao(espera))
            return "espera"
        if st in (401, 403):
            return self._pausar(est, "a chave de edicao nao confere com esta estacao. Rode com --esquecer-chave "
                                     "e de novo para digitar a chave certa.")
        if st == 404 and _eh_404_da_estacao(det):
            return self._pausar(est, "a estacao nao existe em %s (excluida ou id errado? confira --estacao)."
                                % self.api)
        if 300 <= st < 400:
            return self._pausar(est, "a API respondeu com redirecionamento (HTTP %d); por seguranca a chave nao e "
                                     "reenviada. Confira --api (use o endereco https:// exato)." % st)
        if st == 400 and "https" in det.lower():
            return self._pausar(est, "o servidor exige HTTPS para gravar: use --api https://...")
        if st >= 500:
            return self._falha_temporaria(fila, est, "A API respondeu HTTP %d (acordando ou fora do ar)" % st)
        # 404 generico (proxy/deploy), 408, 409, 425 e outros 4xx sem prova de erro permanente: tenta de novo
        return self._falha_temporaria(fila, est, "A API respondeu HTTP %d (%s)" % (st, det[:80] or "sem detalhe"))

    def _reenviar_futuro(self, est, leitura, cab):
        """422 'ts no futuro' = relogio do PC adiantado. Nunca descarta por isso: corrige o ts pela hora do
        servidor (header Date) e reenvia; sem Date (ou se ainda recusar), reenvia sem ts (o servidor carimba).
        -> (leitura_enviada, status, cabecalhos, corpo). Erro de rede sobe (a leitura fica na fila)."""
        srv = _hora_servidor(cab)
        defasagem = None if srv is None else srv - self._relogio()     # negativo = PC adiantado
        if self._mono() - self._aviso_futuro >= 3600:
            self._aviso_futuro = self._mono()
            log.warning("O servidor achou o horario da leitura no futuro: o relogio deste computador esta adiantado%s. "
                        "Acerte a hora do sistema (Windows: Configuracoes > Hora e idioma > Sincronizar agora). Ate la "
                        "corrijo o horario das leituras pela hora do servidor.",
                        (" ~%s" % _duracao(-defasagem)) if defasagem is not None and defasagem < 0 else "")
        if defasagem is not None and defasagem < -60:
            try:
                nova = dict(leitura, ts=iso_utc(datetime.fromisoformat(leitura["ts"]).timestamp() + defasagem))
            except (KeyError, ValueError, TypeError, OverflowError):
                nova = None
            if nova is not None:
                st, cab2, corpo = self._chamar(est, "medicoes", nova)
                if not (st == 422 and "futuro" in _detalhe(corpo).lower()):
                    return nova, st, cab2, corpo
        sem_ts = {k: v for k, v in leitura.items() if k != "ts"}
        st, cab2, corpo = self._chamar(est, "medicoes", sem_ts)
        return sem_ts, st, cab2, corpo

    def _pausar(self, est, motivo):
        self.pausadas[est] = motivo
        self._reverificar_em[est] = self._mono() + REVERIFICAR_PAUSA_S
        log.error("Estacao '%s' pausada: %s As leituras continuam guardadas na fila; confiro a chave de novo a "
                  "cada %s e volto a enviar sozinho se ela valer.", est, motivo, _duracao(REVERIFICAR_PAUSA_S))
        return "pausa"

    def _falha_temporaria(self, fila, est, motivo):
        b = min(BACKOFF_MAX_S, max(BACKOFF_MIN_S, self._backoff.get(est, 0) * 2))
        self._backoff[est] = b
        self._proxima[est] = self._mono() + b
        log.warning("%s. %d leitura(s) na fila; nova tentativa em %s.", motivo, fila.contar(est), _duracao(b))
        return "espera"


class EnviadorSeco:
    """--seco: mostra o que seria enviado. Nao usa chave nem rede."""

    def __init__(self, api):
        self.api = api.rstrip("/")
        self.pausadas = {}
        self.enviadas = 0

    def bloqueada(self, estacao, folga_s=0.0):
        return False

    def processar(self, fila, max_envios=50):
        n = 0
        for it in fila.pendentes()[:max_envios]:
            url = "%s/api/estacoes/%s/medicoes" % (self.api, urllib.parse.quote(it["estacao"], safe=""))
            print("[seco] enviaria POST %s  %s" % (url, json.dumps(it["leitura"], ensure_ascii=True)))
            sys.stdout.flush()
            fila.confirmar(it["seq"])
            self.enviadas += 1
            n += 1
        return n


# ============================================================ chave de edicao
def nome_env_chave(estacao):
    return "CLIMA_CHAVE_" + re.sub(r"[^A-Z0-9]", "_", estacao.upper())


def chave_valida(ch):
    # so ASCII visivel: impede quebra de linha no header (e a lib http ecoar o valor num erro)
    return isinstance(ch, str) and 8 <= len(ch) <= 200 and all(33 <= ord(c) <= 126 for c in ch)


def _arq_chaves_padrao():
    return str(Path.home() / ".clima_aberto_ponte_chaves.json")


def _arq_fila_padrao():
    return str(Path.home() / ".clima_aberto_ponte_fila.jsonl")


def _importar_keyring():
    try:
        import keyring
        kr = keyring.get_keyring()
        if "fail" in type(kr).__module__ or "null" in type(kr).__module__:
            return None
        return keyring
    except Exception:
        return None


class Cofre:
    """Onde a chave fica guardada entre execucoes: cofre do sistema (keyring) ou, sem ele,
    um arquivo JSON so do usuario (permissao 600)."""

    def __init__(self, arquivo=None, keyring_mod="auto"):
        self.arquivo = arquivo or _arq_chaves_padrao()
        self.kr = _importar_keyring() if keyring_mod == "auto" else keyring_mod

    def tem_keyring(self):
        return self.kr is not None

    def _ler_arquivo(self):
        try:
            with open(self.arquivo, "r", encoding="utf-8") as f:
                d = json.load(f)
        except (OSError, ValueError):
            return {}
        ch = d.get("chaves") if isinstance(d, dict) else None
        return dict(ch) if isinstance(ch, dict) else {}

    def _gravar_arquivo(self, chaves):
        if not chaves:
            try:
                os.remove(self.arquivo)
            except OSError:
                pass
            return
        tmp = self.arquivo + ".tmp"
        fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            json.dump({"aviso": "chaves de edicao do Clima Aberto (segredo; nao compartilhe)", "chaves": chaves}, f)
            f.flush()
            os.fsync(f.fileno())
        try:
            os.chmod(tmp, 0o600)
        except OSError:
            pass
        _substituir(tmp, self.arquivo)

    def ler(self, estacao):
        if self.kr is not None:
            try:
                ch = self.kr.get_password(SERVICO_KEYRING, estacao)
                if ch:
                    return ch, "cofre do sistema (keyring)"
            except Exception as ex:
                log.warning("Nao consegui ler o cofre do sistema (%s).", type(ex).__name__)
        ch = self._ler_arquivo().get(estacao)
        if isinstance(ch, str) and ch:
            return ch, "arquivo %s" % self.arquivo
        return None, None

    def guardar_keyring(self, estacao, chave):
        try:
            self.kr.set_password(SERVICO_KEYRING, estacao, chave)
            return True
        except Exception as ex:
            log.warning("Nao consegui gravar no cofre do sistema (%s).", type(ex).__name__)
            return False

    def guardar_arquivo(self, estacao, chave):
        d = self._ler_arquivo()
        d[estacao] = chave
        try:
            self._gravar_arquivo(d)
            return True
        except OSError as ex:
            log.warning("Nao consegui gravar %s (%s).", self.arquivo, type(ex).__name__)
            return False

    def esquecer(self, estacao):
        onde = []
        if self.kr is not None:
            try:
                if self.kr.get_password(SERVICO_KEYRING, estacao):
                    self.kr.delete_password(SERVICO_KEYRING, estacao)
                    onde.append("cofre do sistema")
            except Exception as ex:
                log.warning("Nao consegui apagar do cofre do sistema (%s).", type(ex).__name__)
        d = self._ler_arquivo()
        if estacao in d:
            del d[estacao]
            self._gravar_arquivo(d)
            onde.append("arquivo %s" % self.arquivo)
        return onde


def _terminal_interativo():
    """Da para digitar a chave com seguranca? (no Windows, NUL/pipe tambem dizem isatty=True)."""
    try:
        if sys.stdin is None or not sys.stdin.isatty():
            return False
    except (AttributeError, ValueError, OSError):
        return False
    if os.name == "nt":
        try:
            import ctypes
            import msvcrt
            modo = ctypes.c_uint32()
            return bool(ctypes.windll.kernel32.GetConsoleMode(msvcrt.get_osfhandle(sys.stdin.fileno()),
                                                              ctypes.byref(modo)))
        except Exception:
            return True
    return True


def obter_chave(estacao, n_estacoes, cofre, entrada=None, interativo=None):
    """-> (chave, origem). Ordem: CLIMA_CHAVE_<ESTACAO> > CLIMA_CHAVE (so com 1 estacao) >
    cofre/arquivo > pergunta (getpass). Nunca devolve a chave em mensagem de erro."""
    nomes = [nome_env_chave(estacao)] + (["CLIMA_CHAVE"] if n_estacoes == 1 else [])
    for nome in nomes:
        v = os.environ.get(nome, "").strip()
        if v:
            if not chave_valida(v):
                raise ErroChave("A variavel %s nao parece uma chave de edicao (so caracteres visiveis, "
                                "sem espacos, 8 a 200)." % nome)
            return v, "variavel de ambiente %s" % nome
    ch, origem = cofre.ler(estacao)
    if ch:
        if not chave_valida(ch):
            raise ErroChave("A chave guardada (%s) parece corrompida. Rode com --esquecer-chave e de novo." % origem)
        return ch, origem
    if interativo is None:
        interativo = _terminal_interativo()
    if not interativo:
        raise ErroChave("Sem chave para a estacao '%s' e este terminal nao deixa digitar com seguranca. Rode uma vez "
                        "no PowerShell ou no Prompt de Comando (no Git Bash: winpty %s ponte.py ...) para digitar "
                        "e guardar, ou defina a variavel %s." % (estacao, cmd_python(), nome_env_chave(estacao)))
    entrada = entrada or getpass.getpass
    log.info("A chave de edicao e a que o site mostrou no cadastro da estacao '%s' (fica salva no navegador "
             "onde voce cadastrou).", estacao)
    for _ in range(3):
        try:
            ch = (entrada("Chave de edicao de '%s' (nao aparece ao digitar; cole e tecle Enter): " % estacao)
                  or "").strip()
        except EOFError:
            ch = ""
        if chave_valida(ch):
            return ch, "digitada"
        log.warning("Isso nao parece uma chave de edicao (8 a 200 caracteres visiveis, sem espacos). Tente de novo.")
    raise ErroChave("Chave nao informada.")


def _limpar_entrada():
    """Descarta o que foi digitado/colado ENQUANTO a ponte esperava o servidor (ex.: a chave colada de novo
    porque a tela ficou parada): sem isto a proxima pergunta leria isso como resposta (e o terminal ecoaria)."""
    try:
        if not _terminal_interativo():
            return
        if os.name == "nt":
            import msvcrt
            n = 0
            while msvcrt.kbhit() and n < 100000:
                msvcrt.getwch()
                n += 1
        else:
            import termios
            termios.tcflush(sys.stdin, termios.TCIFLUSH)
    except Exception:
        pass


_RESP_SIM = ("s", "sim", "y", "yes")
_RESP_NAO = ("n", "nao", "n" + chr(0xE3) + "o", "no")   # 'nao' com til; o arquivo fica 100% ASCII


def _perguntar_padrao(msg, padrao, ler=None, limpar=None):
    """Pergunta s/n. So aceita s/sim/n/nao (ou Enter = padrao). Resposta com cara de CHAVE nao conta como 'sim'
    (uma chave que comeca com 's' gravaria em texto puro sem consentimento): pergunta de novo, sem ecoar."""
    ler = ler or input
    limpar = limpar or _limpar_entrada
    for _ in range(3):
        limpar()
        try:
            r = (ler(msg) or "").strip().lower()
        except EOFError:
            return padrao
        if not r:
            return padrao
        if r in _RESP_SIM:
            return True
        if r in _RESP_NAO:
            return False
        if chave_valida(r):
            log.warning("Isso parece a CHAVE, nao uma resposta: nada foi guardado. Responda s (sim) ou n (nao).")
        else:
            log.warning("Responda s (sim) ou n (nao); Enter = %s.", "sim" if padrao else "nao")
    return padrao


def oferecer_guardar(estacao, chave, cofre, perguntar=None):
    perguntar = perguntar or _perguntar_padrao
    if cofre.tem_keyring():
        if perguntar("Guardar a chave de '%s' no cofre do sistema (Gerenciador de Credenciais / Chaveiro)? "
                     "[S/n] " % estacao, True):
            if cofre.guardar_keyring(estacao, chave):
                log.info("Chave guardada no cofre do sistema. Para apagar: --esquecer-chave")
                return "keyring"
        else:
            return None
    else:
        log.info("Dica: com '%s -m pip install keyring' a chave fica no cofre do sistema. Sem guardar a chave, o "
                 "inicio automatico (Agendador de Tarefas / servico) nao funciona: ele nao tem onde digita-la.",
                 cmd_python())
    if perguntar("Guardar a chave no arquivo %s? Fica em texto puro, protegida so pela sua conta de usuario. "
                 "[s/N] " % cofre.arquivo, False):
        if cofre.guardar_arquivo(estacao, chave):
            log.warning("Chave guardada em TEXTO PURO em %s (permissao so do seu usuario; qualquer programa "
                        "rodando com a sua conta consegue le-la). Para apagar: --esquecer-chave", cofre.arquivo)
            return "arquivo"
    log.info("Chave nao guardada: na proxima vez eu pergunto de novo (ou use a variavel %s).",
             nome_env_chave(estacao))
    return None


# ============================================================ a ponte (independe da lib)
class Ponte:
    """Recebe pacotes (dicts no formato da lib meshtastic), filtra SO o(s) no(s) das estacoes,
    deduplica, mapeia, junta numa janela (ambiente/energia/dispositivo chegam em pacotes
    separados) e poe a leitura na fila. Nunca faz rede: e chamada da thread da lib.

    relogio = hora de parede (ts das leituras, comparacao com rxTime/lastHeard do no); janelas, rajada
    e silencio usam o relogio MONOTONICO (acertar a hora do PC nao congela nada)."""

    def __init__(self, estacoes, fila, intervalo=INTERVALO_PADRAO_S, relogio=time.time,
                 primeira_janela_s=PRIMEIRA_JANELA_S, janela_rajada_s=JANELA_RAJADA_S,
                 primeira_janela_max_s=PRIMEIRA_JANELA_MAX_S, monotonico=None):
        self._cfg = [{"estacao": e["estacao"], "no": e.get("no")} for e in estacoes]
        self.fila = fila
        self.intervalo = max(0, int(intervalo))
        self.primeira_janela_s = primeira_janela_s
        self.primeira_janela_max_s = primeira_janela_max_s
        self.janela_rajada_s = janela_rajada_s
        self._relogio = relogio
        self._mono = monotonico or (time.monotonic if relogio is time.time else relogio)
        self._lock = threading.Lock()
        self._por_no = {e["no"]: e["estacao"] for e in self._cfg if e["no"] is not None}
        self._direto = any(e["no"] is None for e in self._cfg)   # alguma estacao = o proprio no conectado
        self.no_local = None
        self._vistos = collections.OrderedDict()
        self._janelas = {}
        self._ja_fechou = set()
        self._ultimo_ts = {}       # estacao -> maior ts (parede) ja posto na fila (o instantaneo nao repete)
        self._ao_vivo = set()      # estacoes com leitura AO VIVO na fila (o instantaneo do banco nao conta)
        self._com_ambiente = set()  # ... e com temperatura/umidade/pressao
        self._ultimo_rx = {}       # estacao -> monotonico do ultimo pacote aceito
        self._conectado_em = None  # monotonico
        self._defasagem = collections.deque(maxlen=15)   # (relogio do PC - rxTime) de pacotes AO VIVO
        self._avisou_defasagem = False
        self._avisou_outro = set()
        self._diag = {}
        self.contagem = collections.Counter()

    def ids(self):
        return [e["estacao"] for e in self._cfg]

    def no_da_estacao(self, estacao):
        with self._lock:
            for num, est in self._por_no.items():
                if est == estacao:
                    return num
        return None

    def definir_no_local(self, num):
        with self._lock:
            antigo, self.no_local = self.no_local, num
            if antigo is not None and antigo != num:
                log.warning("O no conectado mudou (%s -> %s).", id_no(antigo), id_no(num))
            for e in self._cfg:
                if e["no"] is not None:
                    continue
                if antigo is not None and self._por_no.get(antigo) == e["estacao"]:
                    del self._por_no[antigo]
                dono = self._por_no.get(num)
                if dono is not None and dono != e["estacao"]:
                    log.error("O no %s ja e da estacao '%s'; '%s' fica sem no.", id_no(num), dono, e["estacao"])
                    continue
                self._por_no[num] = e["estacao"]

    def conectado(self, agora=None):
        self._conectado_em = self._mono() if agora is None else agora

    def ultimo_rx(self):
        """monotonico do ultimo pacote aceito (qualquer estacao) ou None."""
        with self._lock:
            return max(self._ultimo_rx.values()) if self._ultimo_rx else None

    def estacoes_com_leitura(self):
        """Estacoes com leitura AO VIVO posta na fila (o instantaneo do banco de nos nao conta)."""
        with self._lock:
            return set(self._ao_vivo)

    def estacoes_com_ambiente(self):
        """... e cuja leitura trouxe temperatura, umidade ou pressao."""
        with self._lock:
            return set(self._com_ambiente)

    def defasagem_s(self):
        """Mediana de (relogio do PC - rxTime do no) nos pacotes ao vivo; 0 enquanto nao ha medida."""
        with self._lock:
            amostras = sorted(self._defasagem)
        return amostras[len(amostras) // 2] if amostras else 0.0

    def receber_pacote(self, packet, agora=None, mono=None):
        """-> motivo: aceito | outro_no | nao_telemetria | mqtt | duplicado | antigo | rajada | sem_medida | invalido
        agora = hora de parede (padrao: relogio); mono = monotonico (padrao: agora, se dado, senao o monotonico)."""
        if mono is None:
            mono = self._mono() if agora is None else agora
        agora = self._relogio() if agora is None else agora
        r = self._receber(packet, agora, mono)
        self.contagem[r] += 1
        return r

    def _receber(self, packet, agora, mono):
        if not isinstance(packet, dict):
            return "invalido"
        frm = packet.get("from")
        if not isinstance(frm, int) or isinstance(frm, bool):
            return "invalido"
        with self._lock:
            est = self._por_no.get(frm)
        dec = packet.get("decoded")
        e_telemetria = isinstance(dec, dict) and dec.get("portnum") in ("TELEMETRY_APP", 67)
        if est is None:
            log.debug("Leitura ignorada: veio de outro no (%s).", id_no(frm))
            if e_telemetria:
                self._talvez_avisar_outro(frm, dec.get("telemetry"))
            return "outro_no"
        if not e_telemetria:
            return "nao_telemetria"
        if packet.get("viaMqtt") or packet.get("via_mqtt"):
            # veio pela INTERNET (downlink MQTT do no), nao pelo radio: qualquer um que publique no broker
            # poderia se passar pela estacao
            log.debug("Leitura ignorada: chegou pela internet (MQTT), nao pelo radio.")
            return "mqtt"
        pid = packet.get("id")
        if isinstance(pid, int) and not isinstance(pid, bool) and pid:
            with self._lock:
                if (frm, pid) in self._vistos:
                    return "duplicado"
                self._vistos[(frm, pid)] = mono
                while len(self._vistos) > 2000:
                    self._vistos.popitem(last=False)
        rx = packet.get("rxTime")
        rx_ok = isinstance(rx, int) and not isinstance(rx, bool) and rx > EPOCA_VALIDA
        if self._conectado_em is not None and 0 <= mono - self._conectado_em < self.janela_rajada_s:
            # logo apos conectar o no despeja a fila dele: podem ser pacotes VELHOS. Sem horario -> descarta.
            # Com horario, compara com o relogio do PC descontando a defasagem PC-no ja medida ao vivo.
            if not rx_ok:
                log.debug("Leitura ignorada: chegou na rajada da conexao (pode ser velha, sem horario).")
                return "rajada"
            if agora - rx - self.defasagem_s() > PACOTE_VELHO_S:
                log.debug("Leitura ignorada: antiga (recebida pelo no %s atras).", _duracao(agora - rx))
                return "antigo"
        elif rx_ok:
            # pacote AO VIVO: nunca descartado pelo horario (o relogio do PC pode estar errado); so mede a defasagem
            self._medir_defasagem(agora - rx)
        medidas = mapear_telemetria(dec.get("telemetry"))
        ok, fora = separar_plausiveis(medidas)
        self._diagnosticar(est, dec.get("telemetry"), ok, fora, mono)
        if not ok:
            log.debug("Pacote de telemetria sem medida aproveitavel (variante %s).",
                      ",".join(k for k in (dec.get("telemetry") or {}) if k not in ("raw", "time")) or "?")
            return "sem_medida"
        with self._lock:
            j = self._janelas.get(est)
            if j is None:
                j = self._janelas[est] = {"m": {}, "inicio": mono, "ultimo": agora}
            j["m"].update(ok)
            j["ultimo"] = agora
            self._ultimo_rx[est] = mono
        log.debug("Telemetria aceita da estacao '%s': %s", est, _resumo(ok))
        return "aceito"

    def _medir_defasagem(self, d):
        with self._lock:
            self._defasagem.append(d)
            n = len(self._defasagem)
        med = self.defasagem_s()
        if n >= 3 and abs(med) > DEFASAGEM_AVISO_S and not self._avisou_defasagem:
            self._avisou_defasagem = True
            log.warning("O relogio deste computador e o do no diferem em ~%s (o do PC esta %s). O horario das "
                        "leituras vem do PC: se a hora dele estiver errada, acerte-a (Windows: Configuracoes > Hora e "
                        "idioma > Sincronizar agora).", _duracao(abs(med)), "adiantado" if med > 0 else "atrasado")

    def _talvez_avisar_outro(self, frm, tel):
        """Estacao ligada ao no deste PC, mas chegou temperatura de OUTRO no: talvez a estacao seja ele
        (gateway). Avisa 1 vez por no, sem depender de -v."""
        if frm == self.no_local or frm in self._avisou_outro:
            return
        with self._lock:
            ligada_aqui = self._direto or (self.no_local is not None and self.no_local in self._por_no)
        if not ligada_aqui:
            return
        amb = _variante(tel, "environmentMetrics") if isinstance(tel, dict) else None
        if not amb or _campo(amb, "temperature") is None:
            return
        self._avisou_outro.add(frm)
        log.info("Recebi temperatura do no %s pelo radio e ignorei: a estacao configurada e o no ligado neste PC. "
                 "Se a estacao e o %s (longe, so no radio), rode com --no %s.", id_no(frm), id_no(frm), id_no(frm))

    def _diagnosticar(self, est, tel, ok, fora=(), agora=0.0):
        d = self._diag.setdefault(est, {"so_tensao": 0, "bme": False, "gnd": False, "fora": {}})
        novos = [k for k in fora if agora - d["fora"].get(k, -1e18) >= 3600]   # no maximo 1 aviso/h por campo
        if novos:
            for k in novos:
                d["fora"][k] = agora
            log.warning("Estacao '%s': valor fora do plausivel descartado em %s (sensor com defeito?).",
                        est, ", ".join(novos))
        amb = _variante(tel, "environmentMetrics") if isinstance(tel, dict) else None
        if amb is not None and "bateria_v" in ok and "temp_c" not in ok:
            d["so_tensao"] += 1
            if d["so_tensao"] >= 3 and not d["bme"]:
                d["bme"] = True
                log.warning("A estacao '%s' manda tensao mas nao temperatura: o BME280 nao foi detectado no boot "
                            "(confira cabos e endereco 0x76; reinicie o no).", est)
        elif "temp_c" in ok:
            d["so_tensao"] = 0
        if "bateria_v" in ok and ok["bateria_v"] < 5 and not d["gnd"]:
            d["gnd"] = True
            log.warning("Tensao da bateria de '%s' muito baixa (%.2f V). Se a bateria e de 12 V, ligue o negativo "
                        "dela ao GND do Heltec/INA219 (sem terra comum o INA219 le tensao flutuante).",
                        est, ok["bateria_v"])

    def tick(self, agora=None, forcar=False):
        """Fecha as janelas vencidas e poe as leituras na fila. agora = monotonico. -> quantas leituras."""
        mono = self._mono() if agora is None else agora
        prontas = []
        with self._lock:
            for est in list(self._janelas):
                j = self._janelas[est]
                idade = mono - j["inicio"]
                if est in self._ja_fechou:
                    pronta = idade >= self.intervalo
                else:
                    tem_amb = any(k in j["m"] for k in CAMPOS_AMBIENTE)
                    pronta = idade >= min(self.primeira_janela_max_s, self.intervalo) or (
                        tem_amb and idade >= min(self.primeira_janela_s, self.intervalo))
                if forcar or pronta:
                    prontas.append((est, self._janelas.pop(est)))
                    self._ja_fechou.add(est)
        n = 0
        for est, j in prontas:
            leitura = montar_leitura(j["m"], j["ultimo"])
            if leitura:
                self.fila.adicionar(est, leitura)
                with self._lock:
                    self._ultimo_ts[est] = max(self._ultimo_ts.get(est, 0), j["ultimo"])
                    self._ao_vivo.add(est)
                    if any(k in leitura for k in CAMPOS_AMBIENTE):
                        self._com_ambiente.add(est)
                n += 1
                log.debug("Leitura pronta para '%s': %s", est, _resumo(leitura))
        return n

    def esvaziar(self, agora=None):
        return self.tick(agora, forcar=True)

    def instantaneo(self, nos, agora=None):
        """Ao conectar (modo gateway): se o banco de nos ja tem metricas do no REMOTO da estacao ouvido ha < 2 h,
        poe esse instantaneo na fila (ts = lastHeard). So uma vez por valor de lastHeard. O proprio no conectado
        fica de fora: o firmware poe lastHeard = AGORA nele ao conectar e so traz deviceMetrics (nao e leitura)."""
        agora = self._relogio() if agora is None else agora
        if not isinstance(nos, dict):
            return 0
        with self._lock:
            pares = [(num, est) for num, est in self._por_no.items() if num != self.no_local]
        n = 0
        for num, est in pares:
            no = nos.get(num)
            if not isinstance(no, dict):
                continue
            lh = no.get("lastHeard")
            if not isinstance(lh, int) or isinstance(lh, bool) or lh <= EPOCA_VALIDA:
                continue
            idade = agora - lh
            if idade > INSTANTANEO_MAX_IDADE_S or idade < -TOLERANCIA_FUTURO_S:
                continue
            with self._lock:
                if lh <= self._ultimo_ts.get(est, 0):
                    continue
            tel = {k: no[k] for k in ("environmentMetrics", "powerMetrics", "deviceMetrics", "airQualityMetrics")
                   if isinstance(no.get(k), dict)}
            leitura = montar_leitura(mapear_telemetria(tel), min(lh, agora))
            if not leitura:
                continue
            self.fila.adicionar(est, leitura)
            with self._lock:
                self._ultimo_ts[est] = lh
            n += 1
            log.info("Estacao '%s': ultima leitura do banco de nos (ouvida %s) vai para o mapa ja: %s",
                     est, _quando(max(0, idade)), _resumo(leitura))
        return n


# ============================================================ transporte meshtastic (camada fina)
_ROTEADOR = {"iface": None, "alvo": None, "perdida": None}
_ASSINADO = []


def _ao_receber_telemetria(packet, interface):
    """Assinatura EXATA (packet, interface): o pypubsub fixa o formato do topico pelo 1o assinante.
    Funcao de modulo = referencia forte (o pypubsub guarda so weakref). Nada de rede aqui."""
    if interface is not _ROTEADOR["iface"]:
        return
    alvo = _ROTEADOR["alvo"]
    if alvo is None:
        return
    try:
        alvo.receber_pacote(packet)
    except Exception as ex:   # nunca derruba a thread de publicacao da lib
        log.warning("Erro ao tratar um pacote (%s); seguindo.", type(ex).__name__)


def _ao_perder_conexao(interface):
    if interface is _ROTEADOR["iface"]:
        _ROTEADOR["perdida"] = time.monotonic()


def _ao_estabelecer(interface):
    if interface is _ROTEADOR["iface"]:      # reboot do no com a porta aberta: a lib se recupera sozinha
        _ROTEADOR["perdida"] = None
        alvo = _ROTEADOR["alvo"]
        if alvo is not None and hasattr(alvo, "conectado"):
            alvo.conectado()


def _versao_tupla(v):
    return tuple(int(x) for x in re.findall(r"\d+", str(v))[:3])


def instrucao_instalar(versao=None, nome_so=None, em_venv=None):
    """Mensagem de 'falta a lib', com o comando certo para ESTE Python (copiar e colar)."""
    versao = tuple(sys.version_info[:2]) if versao is None else tuple(versao[:2])
    nome_so = os.name if nome_so is None else nome_so
    py = cmd_python(nome_so, versao, em_venv)
    pacotes = "\"meshtastic==%s\" keyring" % MESHTASTIC_RECOMENDADA
    if versao >= (3, 15):
        alt = "py -3.14" if nome_so == "nt" else "python3.14"
        return ("Falta a biblioteca meshtastic, e no Python %d.%d a meshtastic 2.7 nao instala (o pip pegaria a 2.3, "
                "velha, sem avisar). Instale o Python 3.14 (%s) e use:  %s -m pip install %s   e depois   %s ponte.py ..."
                % (versao[0], versao[1], "py install 3.14, ou python.org" if nome_so == "nt" else "python.org",
                   alt, pacotes, alt))
    return ("Falta a biblioteca meshtastic neste Python. Instale com:  %s -m pip install %s  (Python 3.10 a 3.14)."
            % (py, pacotes))


def _blindar_heartbeat(iface):
    """A lib 2.7.11, a cada reboot do no com a porta aberta, chama _startHeartbeat de novo SEM cancelar o
    timer anterior: as cadeias de heartbeat se acumulam (threads e a interface velha presas). Cancela o
    anterior antes de comecar outro."""
    orig = getattr(iface, "_startHeartbeat", None)
    if orig is None or getattr(orig, "_ponte", False):
        return

    def novo():
        t = getattr(iface, "heartbeatTimer", None)
        if t is not None:
            try:
                t.cancel()
            except Exception:
                pass
        orig()
    novo._ponte = True
    try:
        iface._startHeartbeat = novo
    except Exception:
        pass


def _importar_meshtastic():
    try:
        import meshtastic                      # noqa: F401
        import meshtastic.serial_interface     # noqa: F401
        import meshtastic.tcp_interface        # noqa: F401
        import meshtastic.util                 # noqa: F401
        from pubsub import pub
    except ImportError:
        raise ErroTransporte(instrucao_instalar())
    try:
        from meshtastic.version import get_active_version
        v = get_active_version()
        if _versao_tupla(v) < (2, 5):
            log.warning("meshtastic %s e muito antiga para este firmware. Use Python 3.12-3.14 e "
                        "%s -m pip install \"meshtastic==%s\".", v, cmd_python(), MESHTASTIC_RECOMENDADA)
    except Exception:
        pass
    if not _ASSINADO:
        pub.subscribe(_ao_receber_telemetria, "meshtastic.receive.telemetry")
        pub.subscribe(_ao_perder_conexao, "meshtastic.connection.lost")
        pub.subscribe(_ao_estabelecer, "meshtastic.connection.established")
        _ASSINADO.append(True)
    return meshtastic


def _host_porta(alvo, padrao=4403):
    s = alvo.strip()
    m = re.fullmatch(r"\[([0-9a-fA-F:.]+)\](?::(\d+))?", s)
    if m:
        return m.group(1), int(m.group(2) or padrao)
    if s.count(":") == 1:
        h, p = s.split(":")
        if not p.isdigit():
            raise ErroUso("--tcp invalido: %r (use HOST ou HOST:PORTA)." % alvo)
        return h, int(p)
    return s, padrao


class TransporteMeshtastic:
    """Conexao com o no via lib meshtastic. fabrica(mt) -> interface (testes injetam uma falsa)."""

    def __init__(self, tipo, alvo, sem_nos=True, fabrica=None):
        self.tipo, self.alvo, self.sem_nos = tipo, alvo, sem_nos
        self._fabrica = fabrica
        self.iface = None
        self.descricao = {"serial": "porta %s" % alvo, "tcp": "%s (TCP)" % alvo, "ble": "Bluetooth %s" % alvo}.get(tipo, str(alvo))

    def preparar(self):
        """Confere a lib ANTES de pedir a chave (falha cedo, com a instrucao de instalacao)."""
        _importar_meshtastic()

    def _criar(self, mt):
        if self._fabrica is not None:
            return self._fabrica(mt)
        if self.tipo == "serial":
            porta = self.alvo
            if porta in (None, "", "auto"):
                portas = mt.util.findPorts(True)
                if not portas:
                    raise ErroTransporte("Nenhum radio encontrado na USB. Confira o cabo (de dados, nao so de carga) "
                                         "e o driver CP210x, ou informe a porta: --serial COM3.")
                if len(portas) > 1:
                    raise ErroTransporte("Mais de uma porta serial: %s. Escolha uma, ex.: --serial %s"
                                         % (", ".join(portas), portas[0]))
                porta = portas[0]
                self.descricao = "porta %s" % porta
                log.info("Radio encontrado na %s.", porta)
            return mt.serial_interface.SerialInterface(devPath=porta, noNodes=self.sem_nos)
        if self.tipo == "tcp":
            host, porta = _host_porta(self.alvo)
            return mt.tcp_interface.TCPInterface(hostname=host, portNumber=porta, noNodes=self.sem_nos)
        if self.tipo == "ble":
            try:
                import meshtastic.ble_interface as ble
            except ImportError:
                raise ErroTransporte("Bluetooth precisa do pacote bleak:  %s -m pip install bleak" % cmd_python())
            return ble.BLEInterface(address=self.alvo, noNodes=self.sem_nos)
        raise ErroTransporte("Transporte desconhecido: %s" % self.tipo)

    def _explicar(self, ex):
        nome, txt = type(ex).__name__, str(ex)
        if isinstance(ex, SystemExit):
            return "A biblioteca meshtastic abortou a conexao (mais de uma porta? informe --serial COM3)."
        if self.tipo == "serial" and (nome == "SerialException" or isinstance(ex, (PermissionError, FileNotFoundError))):
            return ("Nao consegui abrir a %s: ela nao existe ou esta em uso. Confira o cabo USB e feche o app "
                    "Meshtastic, o CLI ou o monitor serial que esteja usando a porta." % self.descricao)
        if isinstance(ex, ConnectionRefusedError):
            return ("O no em %s recusou a conexao. O Wi-Fi do no esta ligado? (meshtastic --set "
                    "network.wifi_enabled true)" % self.alvo)
        if isinstance(ex, (socket.timeout, TimeoutError)) and self.tipo == "tcp":
            return ("Sem resposta de %s. Confira o IP do no e se o PC esta na mesma rede (o Wi-Fi do no so "
                    "funciona em 2,4 GHz)." % self.alvo)
        if "Timed out waiting for connection" in txt or "timed out" in txt.lower():
            return ("O no nao respondeu em 30 s. Ele esta ligado e com firmware Meshtastic? Feche outros apps "
                    "conectados a ele.")
        return "Falha ao conectar em %s (%s: %s)." % (self.descricao, nome, txt[:160])

    def conectar(self, destino):
        mt = _importar_meshtastic()
        try:
            iface = self._criar(mt)
        except KeyboardInterrupt:
            raise
        except ErroTransporte:
            raise
        except BaseException as ex:   # inclui o SystemExit que a lib dispara em alguns erros
            raise ErroTransporte(self._explicar(ex))
        _blindar_heartbeat(iface)
        try:
            num = int(iface.myInfo.my_node_num)
        except Exception:
            try:
                iface.close()
            except BaseException:
                pass
            raise ErroTransporte("O no nao informou o proprio id; tente de novo.")
        self.iface = iface
        _ROTEADOR.update(iface=iface, alvo=destino, perdida=None)
        return num

    def nos(self):
        i = self.iface
        n = getattr(i, "nodesByNum", None) if i is not None else None
        return n if isinstance(n, dict) else {}

    def canal_primario_publico(self):
        """True se o canal primario do no usa a senha PADRAO (publica, 1 byte) ou nenhuma; False se a senha e
        propria; None se nao deu para saber. Le so o tamanho da senha; nada disso e guardado ou impresso."""
        try:
            canais = getattr(getattr(self.iface, "localNode", None), "channels", None) or []
            for ch in canais:
                if int(getattr(ch, "role", 0)) == 1:          # Channel.Role.PRIMARY
                    return len(bytes(ch.settings.psk)) <= 1
        except Exception:
            return None
        return None

    def vivo(self):
        i = self.iface
        if i is None:
            return False
        perdida = _ROTEADOR["perdida"]
        if perdida is not None and time.monotonic() - perdida > 30:
            return False
        rx = getattr(i, "_rxThread", None)
        if rx is not None and getattr(rx, "ident", None) is not None and not rx.is_alive():
            return False
        if self.tipo == "serial" and getattr(i, "stream", True) is None:
            return False
        return True

    def fechar(self):
        i, self.iface = self.iface, None
        _ROTEADOR.update(iface=None, alvo=None, perdida=None)   # o close() da lib publica 'lost': ignorado
        if i is not None:
            try:
                i.close()
            except KeyboardInterrupt:
                raise
            except BaseException:
                pass
            t = getattr(i, "heartbeatTimer", None)
            if t is not None:
                try:
                    t.cancel()
                except Exception:
                    pass


def criar_transporte(cfg, sem_nos):
    tipo, alvo = cfg["transporte"]
    return TransporteMeshtastic(tipo, alvo, sem_nos=sem_nos)


# ============================================================ laco principal
class Aplicacao:
    """Laco principal. relogio = MONOTONICO (so agenda: reconexao, silencio, status, espera do --uma-vez)."""

    def __init__(self, ponte, fila, enviador, transporte, reconectar_s=RECONECTAR_S,
                 silencio_s=SILENCIO_MAX_S, passo_s=0.5, status_s=STATUS_S, relogio=time.monotonic,
                 dormir=time.sleep):
        self.ponte, self.fila, self.enviador, self.transporte = ponte, fila, enviador, transporte
        self.reconectar_s, self.silencio_s, self.passo_s, self.status_s = reconectar_s, silencio_s, passo_s, status_s
        self.relogio, self.dormir = relogio, dormir
        self._parar = threading.Event()
        self._parar_envio = threading.Event()
        self._thread_envio = None
        self._conectado = False
        self._conectado_em = 0.0
        self._prox_status = relogio() + status_s

    def parar(self):
        self._parar.set()

    def _conectar(self, de_novo=True):
        try:
            num = self.transporte.conectar(self.ponte)
        except ErroTransporte as ex:
            if de_novo:
                log.warning("%s Nova tentativa em %s.", ex, _duracao(self.reconectar_s))
            else:
                log.error("%s", ex)
            return False
        self.ponte.definir_no_local(num)
        self.ponte.conectado()
        self._conectado, self._conectado_em = True, self.relogio()
        for est in self.ponte.ids():
            no = self.ponte.no_da_estacao(est)
            if no == num:
                log.info("Conectado ao no %s pela %s: ele e a estacao '%s'. Telemetria a cada ~1 min; "
                         "envio a cada %s.", id_no(num), getattr(self.transporte, "descricao", "?"), est,
                         _duracao(self.ponte.intervalo) if self.ponte.intervalo else "pacote")
            elif no is not None:
                log.info("Conectado ao no %s pela %s. A estacao '%s' e o no %s: modo gateway, aguardando a "
                         "telemetria dela pelo LoRa (a cada ~10 min).", id_no(num),
                         getattr(self.transporte, "descricao", "?"), est, id_no(no))
        self._avisar_canal_publico(num)
        self.ponte.instantaneo(self.transporte.nos())
        return True

    def _avisar_canal_publico(self, num):
        """Gateway: telemetria em broadcast nao e autenticada no Meshtastic; com a senha PADRAO do canal, qualquer
        radio pode se passar pela estacao e a ponte enviaria o dado com a SUA chave."""
        remotas = [e for e in self.ponte.ids() if self.ponte.no_da_estacao(e) not in (None, num)]
        f = getattr(self.transporte, "canal_primario_publico", None)
        if not remotas or f is None:
            return
        try:
            publico = f()
        except Exception:
            publico = None
        if publico:
            log.warning("Modo gateway num canal primario com a senha PADRAO (publica): qualquer radio desse canal pode "
                        "se passar pela estacao '%s' e a ponte enviaria o dado com a sua chave. Use um canal primario "
                        "privado na estacao e neste no (guia: Configurar o no > canal primario privado).",
                        ", ".join(remotas))

    def _desconectar(self):
        self._conectado = False
        try:
            self.transporte.fechar()
        except KeyboardInterrupt:
            raise
        except Exception:
            pass

    def _laco_envio(self):
        while not self._parar_envio.is_set():
            try:
                self.enviador.processar(self.fila)
            except Exception as ex:
                log.error("Falha inesperada no envio (%s); sigo tentando.", type(ex).__name__)
            self._parar_envio.wait(1.0)

    def _dica_silencio(self):
        partes = ["Na estacao confira: telemetry.environment_measurement_enabled true e "
                  "environment_update_interval 600 (meshtastic --port COM3 --get telemetry)."]
        if any(self.ponte.no_da_estacao(e) not in (None, self.ponte.no_local) for e in self.ponte.ids()):
            partes.append("No gateway: mesmo canal primario e mesma regiao da estacao (meshtastic --qr na "
                          "estacao e --seturl no gateway; a URL contem a senha do canal, nao compartilhe).")
        return " ".join(partes)

    def _status(self, agora):
        if agora < self._prox_status:
            return
        self._prox_status = agora + self.status_s
        ult = self.ponte.ultimo_rx()
        log.info("Ponte ativa: %d leitura(s) enviada(s), fila %d, ultima telemetria da estacao %s.",
                 getattr(self.enviador, "enviadas", 0), len(self.fila),
                 _quando(agora - ult) if ult else "ainda nenhuma")
        for est, motivo in getattr(self.enviador, "pausadas", {}).items():
            log.warning("Estacao '%s' segue pausada: %s", est, motivo)

    def rodar(self):
        self._thread_envio = threading.Thread(target=self._laco_envio, name="ponte-envio", daemon=True)
        self._thread_envio.start()
        prox_conexao = 0.0
        ultimo_erro = -1e18
        try:
            while not self._parar.is_set():
                agora = self.relogio()
                try:
                    if not self._conectado:
                        if agora >= prox_conexao and not self._conectar():
                            prox_conexao = agora + self.reconectar_s
                    elif not self.transporte.vivo():
                        log.warning("A conexao com o no caiu (cabo USB, reinicio do no ou outro app conectou nele). "
                                    "Reconectando em %s.", _duracao(self.reconectar_s))
                        self._desconectar()
                        prox_conexao = agora + self.reconectar_s
                    elif agora - max(self.ponte.ultimo_rx() or 0, self._conectado_em) >= self.silencio_s:
                        log.warning("Sem telemetria da estacao ha %s. %s Reconectando ao no por garantia.",
                                    _duracao(self.silencio_s), self._dica_silencio())
                        self._desconectar()
                        prox_conexao = agora
                    self.ponte.tick()
                    self._status(agora)
                except Exception as ex:   # bug inesperado numa volta do laco nao derruba a ponte
                    if agora - ultimo_erro > 60:
                        ultimo_erro = agora
                        log.error("Erro inesperado (%s: %s); reconectando em %s.", type(ex).__name__,
                                  str(ex)[:120], _duracao(self.reconectar_s))
                    self._desconectar()
                    prox_conexao = agora + self.reconectar_s
                self.dormir(self.passo_s)
        except KeyboardInterrupt:
            log.info("Encerrando (Ctrl+C)...")
        finally:
            self._encerrar()
        return 0

    def rodar_uma_vez(self, esperar_s):
        """Espera a 1a leitura AO VIVO com ambiente (temperatura/umidade/pressao) de cada estacao -- o pacote da
        placa ou o instantaneo do banco de nos chegam antes e nao bastam --, envia e sai."""
        alvo = set(self.ponte.ids())
        try:
            if not self._conectar(de_novo=False):
                self._encerrar()
                return 1
            t0 = self.relogio()
            log.info("Aguardando a telemetria de ambiente da estacao (ate %s)...", _duracao(esperar_s))
            while self.relogio() - t0 < esperar_s and not self._parar.is_set():
                self.ponte.tick()
                if alvo <= self.ponte.estacoes_com_ambiente():
                    break
                self.dormir(self.passo_s)
        except KeyboardInterrupt:
            log.info("Interrompido.")
        self.ponte.esvaziar()
        self._desconectar()
        com_leitura = alvo & self.ponte.estacoes_com_leitura()
        if not com_leitura:
            log.warning("Nenhuma leitura da estacao em %s. %s", _duracao(esperar_s), self._dica_silencio())
            self._encerrar()
            return 1
        for est in sorted(com_leitura - self.ponte.estacoes_com_ambiente()):
            log.warning("A estacao '%s' mandou so dados da placa/bateria em %s: nada de temperatura, umidade ou pressao. "
                        "Confira o BME280 (cabos, endereco 0x76; reinicie o no). %s", est, _duracao(esperar_s),
                        self._dica_silencio())
        limite = self.relogio() + 120
        try:
            while self.relogio() < limite:
                self.enviador.processar(self.fila)
                pendentes = [e for e in self.fila.estacoes() if e in alvo]
                if not pendentes or all(self.enviador.bloqueada(e, folga_s=10) for e in pendentes):
                    break
                self.dormir(1.0)
        except KeyboardInterrupt:
            log.info("Interrompido.")
        resta = sum(self.fila.contar(e) for e in alvo)
        pausou = any(e in getattr(self.enviador, "pausadas", {}) for e in alvo)
        self._encerrar()
        if pausou:
            return 3
        return 4 if resta else 0

    def _encerrar(self):
        self._parar_envio.set()
        if self._thread_envio is not None and self._thread_envio.is_alive():
            self._thread_envio.join(3.0)   # se estiver no meio de um POST lento, a leitura fica na fila
        self._desconectar()
        self.ponte.esvaziar()
        if isinstance(self.enviador, EnviadorSeco):
            self.enviador.processar(self.fila)
        n = self.fila.contar()
        self.fila.fechar()
        if n and self.fila.caminho:
            log.info("%d leitura(s) guardada(s) em %s; seguem na proxima execucao.", n, self.fila.caminho)


class Listador:
    """--listar: so anota QUANDO (relogio do PC) cada no mandou telemetria. Nada sai daqui."""

    def __init__(self, relogio=time.time):
        self.ouvidos = {}
        self._relogio = relogio

    def receber_pacote(self, packet, agora=None):
        frm = packet.get("from") if isinstance(packet, dict) else None
        if isinstance(frm, int):
            self.ouvidos[frm] = self._relogio() if agora is None else agora

    def conectado(self, agora=None):
        pass


def formatar_lista(nos, meu_num, ouvidos, agora):
    itens = []
    for chave, no in (nos or {}).items():
        if not isinstance(no, dict):
            continue
        try:
            num = int(no.get("num", chave))
        except (TypeError, ValueError):
            continue
        user = no.get("user") if isinstance(no.get("user"), dict) else {}
        nome = _ascii(user.get("shortName") or "?")[:6]
        t = ouvidos.get(num)
        lh = no.get("lastHeard")
        lh = lh if isinstance(lh, int) and not isinstance(lh, bool) and lh > EPOCA_VALIDA else None
        ref = max([x for x in (t, lh) if x is not None], default=None)
        idade = max(0, agora - ref) if ref is not None else None
        amb = no.get("environmentMetrics") if isinstance(no.get("environmentMetrics"), dict) else {}
        temp, umid = _campo(amb, "temperature"), _campo(amb, "relativeHumidity")
        m = mapear_telemetria({k: no[k] for k in ("environmentMetrics", "powerMetrics", "deviceMetrics")
                               if isinstance(no.get(k), dict)})
        if "bateria_v" in m:
            bat = "%.2f V" % m["bateria_v"]
        else:
            nivel = _campo(no.get("deviceMetrics"), "batteryLevel")
            bat = "tomada" if nivel is not None and nivel > 100 else ("%d %%" % nivel if nivel is not None else "-")
        itens.append((num != meu_num, idade if idade is not None else 1e12, num, [
            ("*" if num == meu_num else " ") + id_no(num), nome, _quando(idade),
            "%.1f C" % temp if temp is not None else "-", "%.0f %%" % umid if umid is not None else "-", bat]))
    itens.sort(key=lambda x: (x[0], x[1], x[2]))
    cab = ["  ID", "Nome", "Ouvido", "Temp", "Umid", "Bateria"]
    fmt = "%-11s %-6s %-11s %8s %6s %9s"
    linhas = [fmt % tuple(cab)] + [fmt % tuple(c) for _, _, _, c in itens]
    return linhas


def sugerir_no_remoto(nos, meu_num, ouvidos, agora):
    """Id de um no que NAO e o ligado ao PC (para o exemplo do --no): primeiro os que mandaram temperatura,
    depois o ouvido mais recentemente. None se o radio so conhece ele mesmo."""
    cands = []
    for chave, no in (nos or {}).items():
        if not isinstance(no, dict):
            continue
        try:
            num = int(no.get("num", chave))
        except (TypeError, ValueError):
            continue
        if num == meu_num:
            continue
        lh = no.get("lastHeard")
        lh = lh if isinstance(lh, int) and not isinstance(lh, bool) and lh > EPOCA_VALIDA else None
        ref = max([x for x in (ouvidos.get(num), lh) if x is not None], default=None)
        tem_temp = _campo(no.get("environmentMetrics"), "temperature") is not None
        cands.append((not tem_temp, agora - ref if ref is not None else 1e12, num))
    return min(cands)[2] if cands else None


# ============================================================ CLI
EXEMPLOS = """exemplos (na pasta onde esta o ponte.py):
  {py} ponte.py --estacao vera-cruz --serial COM3
  {py} ponte.py --serial COM3 --listar
  {py} ponte.py --estacao vera-cruz --serial COM5 --no !a1b2c3d4     (gateway: o ID e o da ESTACAO)
  {py} ponte.py --estacao vera-cruz --tcp 192.168.0.50
  {py} ponte.py --config estacoes.json --serial COM3
  {py} ponte.py --estacao vera-cruz --serial COM3 --seco -v --uma-vez

chave: variavel CLIMA_CHAVE_VERA_CRUZ (ou CLIMA_CHAVE), cofre do sistema ({py} -m pip install keyring),
ou digitada na 1a vez. Nunca vai para a URL, nem para o log; so por https.
Mais ajuda: https://clima-aberto.pages.dev/conectar.html"""


def _parser():
    p = argparse.ArgumentParser(
        prog="ponte.py", formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=EXEMPLOS.format(py=cmd_python()), add_help=False,
        description="Ponte Meshtastic -> Clima Aberto: le a telemetria do seu no e envia para a sua estacao "
                    "no mapa. So medidas ambientais/energia saem; nunca nome, posicao, mensagens ou id do no.")
    p.add_argument("-h", "--help", action="help", help="mostra esta ajuda e sai")
    g = p.add_argument_group("conexao com o no (escolha uma)").add_mutually_exclusive_group()
    g.add_argument("--serial", nargs="?", const="auto", metavar="PORTA",
                   help="USB, ex.: COM3 ou /dev/ttyUSB0 (sem valor: detecta sozinho)")
    g.add_argument("--tcp", metavar="HOST[:PORTA]", help="no com Wi-Fi ligado, ex.: 192.168.0.50 (porta 4403)")
    g.add_argument("--ble", metavar="NOME", help="Bluetooth: nome ou endereco (desliga se o Wi-Fi do no estiver ligado)")
    e = p.add_argument_group("estacao")
    e.add_argument("--estacao", metavar="ID", help="id da estacao no Clima Aberto, ex.: vera-cruz")
    e.add_argument("--no", metavar="!xxxxxxxx", help="id Meshtastic do no da estacao (padrao: o proprio no conectado)")
    e.add_argument("--config", metavar="ARQ.json",
                   help='varias estacoes: {"api": "...", "estacoes": [{"no": "!..", "estacao": ".."}]}')
    e.add_argument("--api", metavar="URL", help="padrao %s" % API_PADRAO)
    e.add_argument("--permitir-http-local", action="store_true",
                   help="aceita http:// para IP de rede local (a chave vai SEM criptografia)")
    e.add_argument("--intervalo", type=int, metavar="SEG",
                   help="junta as leituras e envia 1 a cada SEG s (padrao %d; 0 = cada pacote)" % INTERVALO_PADRAO_S)
    e.add_argument("--fila", metavar="ARQ", help="fila offline (padrao ~/.clima_aberto_ponte_fila.jsonl)")
    u = p.add_argument_group("utilitarios")
    u.add_argument("--listar", action="store_true",
                   help="lista os nos que o radio conhece (id, nome, ultima leitura) e sai; nada vai para a internet")
    u.add_argument("--uma-vez", action="store_true", help="espera a 1a leitura de ambiente, envia e sai")
    u.add_argument("--esperar", type=int, metavar="SEG",
                   help="quanto esperar por telemetria (--uma-vez: 120, gateway 660; --listar: 10)")
    u.add_argument("--seco", action="store_true", help="mostra o que enviaria, sem enviar e sem pedir a chave")
    u.add_argument("--esquecer-chave", action="store_true", help="apaga a chave guardada das estacoes e sai")
    u.add_argument("-v", "--verboso", dest="v", action="store_true", help="mostra cada pacote aceito/ignorado")
    u.add_argument("--versao", action="version", version="clima-aberto-ponte " + VERSAO)
    return p


def montar_config(args):
    arq = {}
    if args.config:
        try:
            with open(args.config, "r", encoding="utf-8") as f:
                arq = json.load(f)
        except OSError as ex:
            raise ErroUso("Nao consegui ler --config %s (%s)." % (args.config, type(ex).__name__))
        except ValueError as ex:
            raise ErroUso("--config %s nao e JSON valido (%s)." % (args.config, ex))
        if not isinstance(arq, dict):
            raise ErroUso('--config deve ser um objeto JSON: {"estacoes": [{"no": "!..", "estacao": ".."}]}')
        if any(k in arq for k in ("chave", "chave_edicao", "chaves")) or any(
                isinstance(x, dict) and any(k in x for k in ("chave", "chave_edicao")) for x in arq.get("estacoes") or []):
            log.warning("Ignorei a chave escrita no --config: use a variavel CLIMA_CHAVE_<ESTACAO> ou o cofre do sistema.")
    permitir = bool(args.permitir_http_local or arq.get("permitir_http_local"))
    api, aviso = validar_api(args.api or arq.get("api") or API_PADRAO, permitir)
    estacoes = []
    if args.estacao:
        estacoes = [{"estacao": validar_estacao(args.estacao), "no": ler_id_no(args.no) if args.no else None}]
    else:
        lista = arq.get("estacoes") or []
        if not isinstance(lista, list):
            raise ErroUso('"estacoes" no --config deve ser uma lista.')
        for x in lista:
            if not isinstance(x, dict) or not x.get("estacao"):
                raise ErroUso('Cada item de "estacoes" precisa de "estacao" (e "no", opcional).')
            estacoes.append({"estacao": validar_estacao(x["estacao"]),
                             "no": ler_id_no(x["no"]) if x.get("no") else None})
        if args.no:
            if len(estacoes) != 1:
                raise ErroUso("--no precisa de --estacao (diga de qual estacao e esse no).")
            estacoes[0]["no"] = ler_id_no(args.no)
    ids = [e["estacao"] for e in estacoes]
    if len(set(ids)) != len(ids):
        raise ErroUso("Estacao repetida na configuracao.")
    nos = [e["no"] for e in estacoes if e["no"] is not None]
    if len(set(nos)) != len(nos):
        raise ErroUso("O mesmo no aparece em duas estacoes.")
    if sum(1 for e in estacoes if e["no"] is None) > 1:
        raise ErroUso("So uma estacao pode ficar sem \"no\" (= o proprio no conectado).")
    transporte = None
    for tipo in ("serial", "tcp", "ble"):
        v = getattr(args, tipo)
        if v:
            transporte = (tipo, v)
            break
    if transporte is None:
        for tipo in ("serial", "tcp", "ble"):
            if arq.get(tipo):
                transporte = (tipo, str(arq[tipo]))
                break
    if transporte and transporte[0] == "tcp":
        _host_porta(transporte[1])
    intervalo = args.intervalo if args.intervalo is not None else arq.get("intervalo", INTERVALO_PADRAO_S)
    if not isinstance(intervalo, int) or isinstance(intervalo, bool) or not (intervalo == 0 or 10 <= intervalo <= 86400):
        raise ErroUso("--intervalo deve ser 0 (cada pacote) ou de 10 a 86400 segundos.")
    if args.fila:
        fila = args.fila
    elif arq.get("fila"):
        fila = str(arq["fila"])
    elif args.config:
        fila = str(Path(args.config).with_suffix("")) + ".fila.jsonl"
    else:
        fila = _arq_fila_padrao()
    return {"api": api, "aviso_http": aviso, "estacoes": estacoes, "transporte": transporte,
            "intervalo": intervalo, "fila": fila}


def _console_tolerante():
    for s in (sys.stdout, sys.stderr):
        try:
            s.reconfigure(errors="replace")
        except Exception:
            pass


def _instalar_sigterm():
    def _sinal(signum, frame):
        raise KeyboardInterrupt
    try:
        if threading.current_thread() is threading.main_thread() and hasattr(signal, "SIGTERM"):
            signal.signal(signal.SIGTERM, _sinal)
    except (ValueError, OSError):
        pass


def _verificar_inicio(enviador, estacao, tentativas=3, espera_s=10, dormir=time.sleep):
    cod, det = "rede", ""
    for i in range(tentativas):
        cod, det = enviador.verificar_chave(estacao)
        if cod != "rede":
            return cod, det
        if i < tentativas - 1:
            log.info("A API nao respondeu (%s); ela pode estar acordando (ate ~1 min). Tentando de novo em %s...",
                     det, _duracao(espera_s))
            dormir(espera_s)
    return cod, det


def _cmd_esquecer(cfg, cofre):
    if not cfg["estacoes"]:
        log.error("Diga de qual estacao: --estacao SEU-ID --esquecer-chave")
        return 2
    for e in cfg["estacoes"]:
        est = e["estacao"]
        onde = cofre.esquecer(est)
        if onde:
            log.info("Chave da estacao '%s' apagada de: %s.", est, ", ".join(onde))
        else:
            log.info("Nenhuma chave guardada para '%s'.", est)
        for nome in (nome_env_chave(est), "CLIMA_CHAVE"):
            if os.environ.get(nome):
                log.warning("A variavel %s ainda esta definida no ambiente; apague-a voce mesmo.", nome)
    return 0


def _cmd_listar(cfg, fabrica, esperar, dormir):
    if not cfg["transporte"]:
        log.error("Diga como falar com o no: --serial COM3, --tcp IP ou --ble NOME.")
        return 2
    tr = fabrica(cfg, False)
    lst = Listador()
    try:
        meu = tr.conectar(lst)
    except ErroTransporte as ex:
        log.error("%s", ex)
        return 1
    try:
        log.info("Conectado ao no %s. Ouvindo %s para pegar telemetria recente (nada vai para a internet)...",
                 id_no(meu), _duracao(esperar))
        t0 = time.monotonic()
        while time.monotonic() - t0 < esperar:
            dormir(0.5)
    except KeyboardInterrupt:
        pass
    nos = tr.nos()
    agora = time.time()
    linhas = formatar_lista(nos, meu, lst.ouvidos, agora)
    remoto = sugerir_no_remoto(nos, meu, lst.ouvidos, agora)
    tr.fechar()
    py = cmd_python()
    conexao = {"serial": "--serial %s", "tcp": "--tcp %s", "ble": "--ble %s"}[cfg["transporte"][0]] % cfg["transporte"][1]
    print()
    print("Nos que o radio conhece (lista LOCAL; nada foi enviado ao Clima Aberto):")
    for linha in linhas:
        print(linha)
    print("* = no ligado neste computador. Temperatura/bateria aparecem depois que o no manda telemetria.")
    print("Se a estacao E o no ligado neste computador (*), rode SEM --no:")
    print("  %s ponte.py --estacao SEU-ID %s" % (py, conexao))
    print("Se a estacao esta LONGE (so no radio; este no e o gateway), use o ID DELA, de uma linha sem *:")
    print("  %s ponte.py --estacao SEU-ID %s --no %s" % (py, conexao, id_no(remoto) if remoto else "!xxxxxxxx"))
    if remoto:
        print("  (%s e so um exemplo: confira na lista qual e o no da estacao.)" % id_no(remoto))
    return 0


def _cmd_ponte(cfg, args, fabrica, cofre, entrada, perguntar, interativo, dormir, opcoes, opcoes_ponte):
    estacoes = cfg["estacoes"]
    if not estacoes:
        log.error("Diga qual estacao: --estacao SEU-ID (o id aparece no site, na sua estacao), ou --config.")
        return 2
    if not cfg["transporte"]:
        log.error("Diga como falar com o no: --serial COM3, --tcp IP ou --ble NOME. Para achar a porta: "
                  "--serial sozinho tenta detectar.")
        return 2
    sem_nos = all(e["no"] is None for e in estacoes)   # gateway precisa do banco de nos (instantaneo)
    tr = fabrica(cfg, sem_nos)
    try:
        if hasattr(tr, "preparar"):
            tr.preparar()
    except ErroTransporte as ex:
        log.error("%s", ex)
        return 2
    try:
        fila = Fila(None if args.seco else cfg["fila"])
    except (ErroFila, OSError) as ex:
        log.error("%s", ex)
        return 2
    try:
        ids = [e["estacao"] for e in estacoes]
        if args.seco:
            enviador = EnviadorSeco(cfg["api"])
            log.info("Modo --seco: nada sera enviado e a chave nao e usada.")
        else:
            chaves = {}
            for est in ids:
                ch, origem = obter_chave(est, len(ids), cofre, entrada, interativo)
                registrar_segredo(ch)
                chaves[est] = (ch, origem)
                log.info("Chave da estacao '%s': %s.", est, origem)
            enviador = Enviador(cfg["api"], {k: v[0] for k, v in chaves.items()})
            for est in ids:
                log.info("Conferindo a chave de '%s' com o servidor (ate ~1 min se ele estiver dormindo)...", est)
                cod, det = _verificar_inicio(enviador, est, dormir=dormir)
                if cod == "ok":
                    log.info("Chave conferida para a estacao '%s' em %s.", est, cfg["api"])
                elif cod == "chave_errada":
                    log.error("A chave nao confere com a estacao '%s'. Confira a chave (a do cadastro) e rode de "
                              "novo; se ela estava guardada, use --esquecer-chave antes.", est)
                    return 3
                elif cod == "estacao_inexistente":
                    log.error("A estacao '%s' nao existe em %s. Confira o id (--estacao) e o --api.", est, cfg["api"])
                    return 3
                elif cod == "sem_endpoint":
                    log.warning("O servidor e de uma versao sem 'verificar-chave': sigo sem conferir a chave "
                                "(se estiver errada, aviso no primeiro envio).")
                elif cod == "rede":
                    log.warning("Nao consegui falar com a API agora (%s). Sigo coletando: as leituras ficam na fila "
                                "e seguem quando ela voltar.", det)
                elif cod == "https":
                    log.error("O servidor exige HTTPS para a chave: use --api https://...")
                    return 3
                elif cod == "redirecionamento":
                    log.error("A API respondeu com redirecionamento (%s); por seguranca a chave nao e reenviada. Use o "
                              "endereco https:// exato do site em --api.", det)
                    return 3
                else:
                    # resposta estranha (proxy, deploy no meio...) nao e prova de chave errada: segue coletando;
                    # o envio tenta de novo com espera e so pausa com prova forte (401/403/404 da estacao)
                    log.warning("A API respondeu algo inesperado ao conferir a chave (%s). Sigo coletando: as leituras "
                                "ficam na fila e eu tento enviar de novo. Se persistir, confira --api.", det)
                if chaves[est][1] == "digitada" and cod in ("ok", "sem_endpoint", "rede"):
                    oferecer_guardar(est, chaves[est][0], cofre, perguntar)
        ponte = Ponte(estacoes, fila, intervalo=cfg["intervalo"], **(opcoes_ponte or {}))
        app = Aplicacao(ponte, fila, enviador, tr, dormir=dormir, **(opcoes or {}))
        log.info("Lembrete: feche o app Meshtastic do celular ligado a este no; com dois clientes as leituras se "
                 "dividem entre eles.")
        if args.uma_vez:
            padrao = ESPERAR_UMA_VEZ_S if sem_nos else ESPERAR_UMA_VEZ_GATEWAY_S
            return app.rodar_uma_vez(args.esperar if args.esperar is not None else padrao)
        log.info("Ponte rodando. Ctrl+C para sair.")
        return app.rodar()
    except ErroChave as ex:
        log.error("%s", ex)
        return 3
    except ErroTransporte as ex:
        log.error("%s", ex)
        return 1
    finally:
        fila.fechar()


def main(argv=None, fabrica_transporte=None, cofre=None, entrada=None, perguntar=None, interativo=None,
         dormir=time.sleep, opcoes_aplicacao=None, opcoes_ponte=None):
    if sys.version_info < (3, 9):
        print("ERRO: a ponte precisa de Python 3.9 ou mais novo (recomendado 3.12 a 3.14).")
        return 2
    args = _parser().parse_args(argv)
    _console_tolerante()
    configurar_log(args.v)
    try:
        cfg = montar_config(args)
    except ErroUso as ex:
        log.error("%s", ex)
        return 2
    if cfg["aviso_http"]:
        log.warning("%s", cfg["aviso_http"])
    fabrica = fabrica_transporte or criar_transporte
    if args.esquecer_chave:
        return _cmd_esquecer(cfg, cofre or Cofre())
    if args.listar:
        return _cmd_listar(cfg, fabrica, args.esperar if args.esperar is not None else 10, dormir)
    _instalar_sigterm()
    return _cmd_ponte(cfg, args, fabrica, cofre or Cofre(), entrada, perguntar, interativo, dormir,
                      opcoes_aplicacao, opcoes_ponte)


if __name__ == "__main__":
    try:
        sys.exit(main())
    except KeyboardInterrupt:
        sys.exit(0)
