Add auto-reconnect and diagnostic hints for serial Access Denied errors

The previous fix (port access lock) only addressed contention between
our own read/write threads. A recurring "ClearCommError failed -
Acesso negado" after the lock fix indicates the OS is invalidating the
port handle itself (USB power management suspending the port, the
RFID module browning out under high RF output power, or another app
holding the port) rather than an internal race.

Add:
- A "Reconectar automaticamente" checkbox (on by default) that retries
  opening the port with backoff (up to 5 attempts) after any fatal
  serial error, resuming the scan automatically if one was active.
- A generation counter so stale scheduled reconnects are discarded if
  the user manually connects/disconnects in the meantime.
- An actionable hint logged on fatal errors explaining the likely
  causes and how to fix each one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
lronetto 2026-07-16 15:38:29 +00:00 committed by Claude
parent d93be84bda
commit 22fca0246c

View File

@ -112,6 +112,9 @@ CMD_STOP_MULTI_POLL = 0x28
BAUDRATES = ["9600", "19200", "38400", "57600", "115200"]
DEFAULT_BAUDRATE = "115200"
MAX_RECONNECT_ATTEMPTS = 5
RECONNECT_DELAY_MS = 1500
# --------------------------------------------------------------------------
# Construcao / interpretacao de quadros
@ -263,6 +266,19 @@ class RfidApp(tk.Tk):
# todo acesso a self.ser (leitura e escrita) para evitar a corrida.
self._port_lock = threading.RLock()
# Estado usado pela reconexao automatica: guarda os ultimos
# parametros usados com sucesso, quantas tentativas silenciosas ja
# foram feitas, se uma varredura estava ativa (para retoma-la), e um
# "numero de geracao" que invalida tentativas de reconexao agendadas
# quando o usuario toma uma acao manual (conectar/desconectar) antes
# delas dispararem.
self._last_port = None
self._last_baud = None
self._reconnect_attempts = 0
self._pending_resume_scan = False
self._conn_generation = 0
self.scanning = False
self.tags = {} # epc -> {"count": int, "rssi": int, "last_seen": str}
self._build_ui()
@ -315,6 +331,13 @@ class RfidApp(tk.Tk):
)
self.set_power_btn.grid(row=0, column=8, **pad)
self.auto_reconnect_var = tk.BooleanVar(value=True)
ttk.Checkbutton(
conn_frame,
text="Reconectar automaticamente em caso de erro na porta",
variable=self.auto_reconnect_var,
).grid(row=1, column=0, columnspan=6, sticky="w", padx=6, pady=(0, 4))
# ------------------------------------------------------ Scan controls
scan_frame = ttk.LabelFrame(self, text="Varredura")
scan_frame.pack(fill="x", **pad)
@ -394,29 +417,57 @@ class RfidApp(tk.Tk):
# ------------------------------------------------------------ connect
def _toggle_connection(self):
if self.ser is None:
self._conn_generation += 1 # invalida qualquer reconexao automatica antiga pendente
self._reconnect_attempts = 0
self._connect()
else:
self._disconnect()
def _connect(self):
def _connect(self, silent=False, expected_gen=None):
"""
Abre a porta serial. Quando silent=True, esta sendo chamado pela
rotina de reconexao automatica: usa os ultimos parametros que
funcionaram, nao exibe caixas de dialogo de erro (so registra no
log) e agenda uma nova tentativa em caso de falha.
expected_gen e o "numero de geracao" capturado no momento em que a
tentativa (silenciosa) foi agendada; se o usuario tomou uma acao
manual (conectar/desconectar) enquanto ela esperava, a geracao atual
ja mudou e esta tentativa e descartada sem tocar na porta.
"""
if silent and expected_gen is not None and expected_gen != self._conn_generation:
self._log("Reconexao automatica cancelada (nova acao do usuario).")
return
gen = self._conn_generation
if serial is None:
if not silent:
messagebox.showerror("Erro", "pyserial nao instalado. Rode: pip install pyserial")
return
port = self.port_var.get()
port = self._last_port if silent else self.port_var.get()
baud_source = self._last_baud if silent else self.baud_var.get()
if not port:
if not silent:
messagebox.showwarning("Atencao", "Selecione uma porta serial.")
return
try:
baud = int(self.baud_var.get())
except ValueError:
baud = int(baud_source)
except (TypeError, ValueError):
if not silent:
messagebox.showwarning("Atencao", "Baudrate invalido.")
return
try:
self.ser = serial.Serial(port, baudrate=baud, timeout=0.2, write_timeout=1.0)
ser = serial.Serial(port, baudrate=baud, timeout=0.2, write_timeout=1.0)
except Exception as exc:
if silent:
self._log(f"Falha ao reconectar: {exc}")
self._schedule_reconnect(gen)
else:
messagebox.showerror(
"Erro ao conectar",
f"{exc}\n\n"
@ -424,9 +475,22 @@ class RfidApp(tk.Tk):
"nao esta aberta em outro programa (Monitor Serial da Arduino IDE,\n"
"outra instancia deste app, PuTTY, etc.) e tente novamente.",
)
self.ser = None
return
# se o usuario desconectou/reconectou manualmente enquanto esta
# tentativa (silenciosa) estava em andamento, descarta o resultado
if gen != self._conn_generation:
try:
ser.close()
except Exception:
pass
return
self.ser = ser
self._last_port = port
self._last_baud = baud
self._reconnect_attempts = 0
self.stop_event.clear()
self.parser = FrameParser()
self.reader_thread = threading.Thread(target=self._reader_loop, daemon=True)
@ -440,7 +504,15 @@ class RfidApp(tk.Tk):
self._send(frame_get_info(INFO_SW_VERSION))
self._send(frame_get_power())
if self._pending_resume_scan:
self._pending_resume_scan = False
self._log("Retomando varredura apos reconexao automatica...")
self.after(300, self._start_scan)
def _disconnect(self):
self._conn_generation += 1 # cancela qualquer reconexao automatica pendente
self._reconnect_attempts = 0
self._pending_resume_scan = False
self._stop_scan(silent=True)
self.stop_event.set()
if self.reader_thread is not None:
@ -464,10 +536,24 @@ class RfidApp(tk.Tk):
def _fatal_disconnect(self, reason):
"""
Encerra a conexao apos um erro irrecuperavel (ex.: PermissionError
do Windows) e devolve a interface a um estado consistente, para que
o usuario possa simplesmente clicar em "Conectar" de novo.
do Windows) e devolve a interface a um estado consistente. Se
"Reconectar automaticamente" estiver marcado, agenda novas
tentativas; senao, o usuario so precisa clicar em "Conectar" de novo.
"""
self._log(reason)
self._log(
"Dica: 'Acesso negado' (WinError 5) na porta serial normalmente vem de "
"(1) gerenciamento de energia USB suspendendo a porta - em Gerenciador de "
"Dispositivos, na porta COM, aba Gerenciamento de Energia, desmarque 'Permitir "
"que o computador desligue este dispositivo para economizar energia'; "
"(2) o modulo RFID sofrendo queda de energia/reset ao transmitir em potencia "
"alta - tente reduzir a potencia ou usar um cabo/hub USB com alimentacao "
"externa; ou (3) outro programa (Monitor Serial, outra instancia deste app) "
"usando a mesma porta ao mesmo tempo."
)
self._pending_resume_scan = self.scanning
self.scanning = False
self.stop_event.set()
if self.ser is not None:
with self._port_lock:
@ -477,8 +563,33 @@ class RfidApp(tk.Tk):
pass
self.ser = None
self._set_controls_connected(False)
gen = self._conn_generation
if self.auto_reconnect_var.get():
self.status_var.set("Erro na porta - reconectando...")
self._schedule_reconnect(gen)
else:
self.status_var.set("Desconectado (erro)")
def _schedule_reconnect(self, gen):
if gen != self._conn_generation or not self.auto_reconnect_var.get():
return
if self._reconnect_attempts >= MAX_RECONNECT_ATTEMPTS:
self._log(
f"Numero maximo de tentativas de reconexao automatica ({MAX_RECONNECT_ATTEMPTS}) "
"atingido. Verifique a causa (veja a dica acima) e clique em Conectar manualmente."
)
self._reconnect_attempts = 0
self.status_var.set("Desconectado (erro)")
return
self._reconnect_attempts += 1
self._log(
f"Tentando reconectar automaticamente "
f"({self._reconnect_attempts}/{MAX_RECONNECT_ATTEMPTS}) em "
f"{RECONNECT_DELAY_MS / 1000:.1f}s..."
)
self.after(RECONNECT_DELAY_MS, lambda: self._connect(silent=True, expected_gen=gen))
# -------------------------------------------------------------- serial
def _send(self, frame: bytes):
if self.ser is None:
@ -529,12 +640,14 @@ class RfidApp(tk.Tk):
def _start_scan(self):
self._send(frame_start_multi_poll(count=4095))
self.scanning = True
self.start_btn.configure(state="disabled")
self.stop_btn.configure(state="normal")
self.status_var.set("Varredura em andamento...")
self._log("Varredura continua iniciada")
def _stop_scan(self, silent=False):
self.scanning = False
if self.ser is not None:
self._send(frame_stop_multi_poll())
self.start_btn.configure(state="normal" if self.ser is not None else "disabled")