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>
712 lines
27 KiB
Python
712 lines
27 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
uhf_rfid_gui.py
|
|
================
|
|
|
|
Aplicativo com interface grafica (Tkinter) para Windows, para uso com
|
|
modulos leitores UHF RFID da familia "Fonkan / JRD-100 / M100"
|
|
(ISO18000-6C / EPC Global Class 1 Gen 2, interface serial TTL232),
|
|
como o vendido em:
|
|
https://pt.aliexpress.com/item/1005006138510871.html
|
|
|
|
Funcionalidades:
|
|
- Selecionar porta serial (com botao de atualizar lista)
|
|
- Selecionar baudrate
|
|
- Definir a potencia de saida (dBm) do leitor
|
|
- Conectar / desconectar
|
|
- Iniciar / parar varredura (inventario) de tags
|
|
- Mostrar as tags detectadas em uma tabela (EPC, RSSI, contagem, ultima leitura)
|
|
- Console de log com os quadros brutos (uteis para depuracao, pois o
|
|
fabricante nao disponibiliza datasheet oficial publico - o protocolo
|
|
abaixo foi reconstruido a partir de documentacao de terceiros/comunidade)
|
|
|
|
------------------------------------------------------------------------
|
|
IMPORTANTE SOBRE O PROTOCOLO
|
|
------------------------------------------------------------------------
|
|
A Fonkan nao publica um datasheet/PDF oficial completo para este modulo.
|
|
O protocolo usado aqui foi reconstruido a partir de:
|
|
- Blog "JRD-100 - Arduino UHF-RFID Module Development" (CimpleO Group)
|
|
- Discussoes no forum oficial do Arduino sobre o JRD-100
|
|
- Documentacao de modulos da mesma familia de chipset (comuns em varios
|
|
modulos "clone" vendidos como YRM100/M100/JRD-100/R200), que usam o
|
|
mesmo formato de quadro:
|
|
|
|
Quadro (frame):
|
|
[0] Header = 0xBB
|
|
[1] Type = 0x00 (comando enviado ao leitor)
|
|
0x01 (resposta do leitor)
|
|
0x02 (notificacao / tag lida)
|
|
[2] Command = codigo do comando
|
|
[3-4] PL Len = tamanho do payload, 2 bytes big-endian
|
|
[5..] PL = payload (parametros / dados)
|
|
[.] Checksum = soma (Type+Command+PLLen+PL) & 0xFF
|
|
[.] End = 0x7E
|
|
|
|
Comandos utilizados neste app:
|
|
0x03 Consulta de informacoes do modulo (hw/sw/fabricante)
|
|
0xB6 Definir potencia de saida (RF Output Power), 2 bytes,
|
|
valor em centi-dBm (dBm * 100), big-endian
|
|
0xB7 Consultar potencia de saida atual
|
|
0x22 Inventario/poll unico (single polling)
|
|
0x27 Inventario continuo (multiple polling) - payload:
|
|
[modo=0x22][contagem 2 bytes big-endian]
|
|
0x28 Parar inventario continuo
|
|
|
|
Quadro de notificacao de tag (Type=0x02, Command=0x22):
|
|
PL = [RSSI 1 byte][PC 2 bytes][EPC N bytes][CRC 2 bytes]
|
|
|
|
Como esses valores nao vieram de um datasheet oficial, pode ser
|
|
necessario pequenos ajustes conforme a revisao exata do firmware do seu
|
|
modulo. Use o console de log (quadros brutos em hexadecimal) para
|
|
conferir o que o leitor esta respondendo e ajustar se necessario.
|
|
|
|
------------------------------------------------------------------------
|
|
INSTALACAO (Windows)
|
|
------------------------------------------------------------------------
|
|
1. Instale o Python 3 (inclui Tkinter por padrao no instalador oficial).
|
|
2. Instale a biblioteca pyserial:
|
|
pip install pyserial
|
|
3. Execute:
|
|
python uhf_rfid_gui.py
|
|
"""
|
|
|
|
import struct
|
|
import threading
|
|
import queue
|
|
import time
|
|
from datetime import datetime
|
|
|
|
import tkinter as tk
|
|
from tkinter import ttk, messagebox, scrolledtext
|
|
|
|
try:
|
|
import serial
|
|
import serial.tools.list_ports
|
|
except ImportError:
|
|
serial = None
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Constantes do protocolo
|
|
# --------------------------------------------------------------------------
|
|
FRAME_HEADER = 0xBB
|
|
FRAME_END = 0x7E
|
|
|
|
TYPE_COMMAND = 0x00
|
|
TYPE_RESPONSE = 0x01
|
|
TYPE_NOTIFICATION = 0x02
|
|
|
|
CMD_GET_INFO = 0x03
|
|
INFO_HW_VERSION = 0x00
|
|
INFO_SW_VERSION = 0x01
|
|
INFO_MANUFACTURER = 0x02
|
|
|
|
CMD_SET_POWER = 0xB6
|
|
CMD_GET_POWER = 0xB7
|
|
|
|
CMD_SINGLE_POLL = 0x22
|
|
CMD_MULTI_POLL = 0x27
|
|
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
|
|
# --------------------------------------------------------------------------
|
|
def build_frame(command, params=b""):
|
|
"""Monta um quadro de comando (Type = 0x00) pronto para envio."""
|
|
pl_len = len(params)
|
|
body = bytes([TYPE_COMMAND, command, (pl_len >> 8) & 0xFF, pl_len & 0xFF]) + params
|
|
checksum = sum(body) & 0xFF
|
|
return bytes([FRAME_HEADER]) + body + bytes([checksum, FRAME_END])
|
|
|
|
|
|
def frame_set_power(power_dbm):
|
|
"""Comando para definir a potencia de saida (em dBm, aceita decimais)."""
|
|
centi_dbm = int(round(power_dbm * 100))
|
|
params = struct.pack(">H", centi_dbm & 0xFFFF)
|
|
return build_frame(CMD_SET_POWER, params)
|
|
|
|
|
|
def frame_get_power():
|
|
return build_frame(CMD_GET_POWER)
|
|
|
|
|
|
def frame_get_info(info_id):
|
|
return build_frame(CMD_GET_INFO, bytes([info_id]))
|
|
|
|
|
|
def frame_single_poll():
|
|
return build_frame(CMD_SINGLE_POLL)
|
|
|
|
|
|
def frame_start_multi_poll(count=4095):
|
|
params = bytes([0x22]) + struct.pack(">H", count & 0xFFFF)
|
|
return build_frame(CMD_MULTI_POLL, params)
|
|
|
|
|
|
def frame_stop_multi_poll():
|
|
return build_frame(CMD_STOP_MULTI_POLL)
|
|
|
|
|
|
class FrameParser:
|
|
"""
|
|
Le bytes recebidos da porta serial e extrai quadros completos
|
|
(Header ... End), tolerando fragmentacao / lixo entre quadros.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self._buf = bytearray()
|
|
|
|
def feed(self, data: bytes):
|
|
"""Adiciona bytes recebidos e retorna lista de quadros completos."""
|
|
self._buf.extend(data)
|
|
frames = []
|
|
while True:
|
|
# procura o header
|
|
idx = self._buf.find(bytes([FRAME_HEADER]))
|
|
if idx == -1:
|
|
self._buf.clear()
|
|
break
|
|
if idx > 0:
|
|
del self._buf[:idx]
|
|
|
|
if len(self._buf) < 7:
|
|
break # aguarda mais dados (tamanho minimo de um quadro sem payload)
|
|
|
|
pl_len = (self._buf[3] << 8) | self._buf[4]
|
|
total_len = 7 + pl_len # header+type+cmd+len(2)+pl+checksum+end
|
|
|
|
if len(self._buf) < total_len:
|
|
break # quadro incompleto, aguarda mais dados
|
|
|
|
candidate = bytes(self._buf[:total_len])
|
|
|
|
if candidate[-1] == FRAME_END:
|
|
frames.append(candidate)
|
|
del self._buf[:total_len]
|
|
else:
|
|
# nao terminou em 0x7E onde esperado: descarta o header
|
|
# atual e tenta resincronizar a partir do proximo byte
|
|
del self._buf[:1]
|
|
|
|
return frames
|
|
|
|
|
|
def parse_frame(frame: bytes):
|
|
"""
|
|
Interpreta um quadro ja validado (comeca com 0xBB, termina com 0x7E).
|
|
Retorna um dicionario com os campos decodificados.
|
|
"""
|
|
type_ = frame[1]
|
|
command = frame[2]
|
|
pl_len = (frame[3] << 8) | frame[4]
|
|
pl = frame[5:5 + pl_len]
|
|
checksum = frame[5 + pl_len]
|
|
result = {
|
|
"type": type_,
|
|
"command": command,
|
|
"pl": pl,
|
|
"checksum": checksum,
|
|
"raw": frame,
|
|
}
|
|
|
|
if command == CMD_SINGLE_POLL and pl_len >= 5:
|
|
rssi_raw = pl[0]
|
|
pc = pl[1:3]
|
|
epc = pl[3:pl_len - 2]
|
|
crc = pl[pl_len - 2:pl_len]
|
|
result["kind"] = "tag"
|
|
result["rssi_raw"] = rssi_raw
|
|
# RSSI costuma ser reportado como valor com offset; aqui mostramos
|
|
# tanto o valor bruto quanto uma estimativa comum (raw - 256) para
|
|
# leitores que reportam em complemento de 2.
|
|
result["rssi_dbm"] = rssi_raw - 256 if rssi_raw > 127 else rssi_raw
|
|
result["pc"] = pc.hex().upper()
|
|
result["epc"] = epc.hex().upper()
|
|
result["crc"] = crc.hex().upper()
|
|
elif command == CMD_GET_INFO:
|
|
result["kind"] = "info"
|
|
result["info_text"] = pl.hex().upper()
|
|
elif command in (CMD_SET_POWER, CMD_GET_POWER):
|
|
result["kind"] = "power"
|
|
if len(pl) >= 2:
|
|
result["power_dbm"] = struct.unpack(">H", pl[:2])[0] / 100.0
|
|
else:
|
|
result["kind"] = "other"
|
|
|
|
return result
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Interface grafica
|
|
# --------------------------------------------------------------------------
|
|
class RfidApp(tk.Tk):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.title("Leitor UHF RFID - Fonkan / JRD-100 / M100")
|
|
self.geometry("780x560")
|
|
self.minsize(680, 480)
|
|
|
|
self.ser = None
|
|
self.reader_thread = None
|
|
self.stop_event = threading.Event()
|
|
self.event_queue = queue.Queue()
|
|
self.parser = FrameParser()
|
|
# No Windows, ler e escrever na porta serial ao mesmo tempo a partir
|
|
# de threads diferentes sem sincronizacao costuma gerar
|
|
# PermissionError/WinError 5 ("Acesso negado") em GetOverlappedResult
|
|
# ou WriteFile, dependendo do driver USB-serial. Este lock serializa
|
|
# 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()
|
|
self._refresh_ports()
|
|
self._poll_queue()
|
|
|
|
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
|
|
|
if serial is None:
|
|
messagebox.showerror(
|
|
"Dependencia ausente",
|
|
"A biblioteca 'pyserial' nao foi encontrada.\n\n"
|
|
"Instale com:\n pip install pyserial\n\n"
|
|
"e execute o aplicativo novamente.",
|
|
)
|
|
|
|
# ---------------------------------------------------------- UI layout
|
|
def _build_ui(self):
|
|
pad = {"padx": 6, "pady": 4}
|
|
|
|
conn_frame = ttk.LabelFrame(self, text="Conexao")
|
|
conn_frame.pack(fill="x", **pad)
|
|
|
|
ttk.Label(conn_frame, text="Porta:").grid(row=0, column=0, sticky="w", **pad)
|
|
self.port_var = tk.StringVar()
|
|
self.port_combo = ttk.Combobox(conn_frame, textvariable=self.port_var, width=18, state="readonly")
|
|
self.port_combo.grid(row=0, column=1, **pad)
|
|
|
|
ttk.Button(conn_frame, text="Atualizar", command=self._refresh_ports).grid(row=0, column=2, **pad)
|
|
|
|
ttk.Label(conn_frame, text="Baudrate:").grid(row=0, column=3, sticky="w", **pad)
|
|
self.baud_var = tk.StringVar(value=DEFAULT_BAUDRATE)
|
|
self.baud_combo = ttk.Combobox(
|
|
conn_frame, textvariable=self.baud_var, values=BAUDRATES, width=10, state="readonly"
|
|
)
|
|
self.baud_combo.grid(row=0, column=4, **pad)
|
|
|
|
ttk.Label(conn_frame, text="Potencia (dBm):").grid(row=0, column=5, sticky="w", **pad)
|
|
self.power_var = tk.DoubleVar(value=20.0)
|
|
self.power_spin = ttk.Spinbox(
|
|
conn_frame, from_=15, to=33, increment=1, textvariable=self.power_var, width=6
|
|
)
|
|
self.power_spin.grid(row=0, column=6, **pad)
|
|
|
|
self.connect_btn = ttk.Button(conn_frame, text="Conectar", command=self._toggle_connection)
|
|
self.connect_btn.grid(row=0, column=7, **pad)
|
|
|
|
self.set_power_btn = ttk.Button(
|
|
conn_frame, text="Aplicar potencia", command=self._set_power, state="disabled"
|
|
)
|
|
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)
|
|
|
|
self.start_btn = ttk.Button(scan_frame, text="Iniciar varredura", command=self._start_scan, state="disabled")
|
|
self.start_btn.grid(row=0, column=0, **pad)
|
|
|
|
self.stop_btn = ttk.Button(scan_frame, text="Parar varredura", command=self._stop_scan, state="disabled")
|
|
self.stop_btn.grid(row=0, column=1, **pad)
|
|
|
|
self.single_btn = ttk.Button(scan_frame, text="Leitura unica", command=self._single_poll, state="disabled")
|
|
self.single_btn.grid(row=0, column=2, **pad)
|
|
|
|
ttk.Button(scan_frame, text="Limpar tabela", command=self._clear_tags).grid(row=0, column=3, **pad)
|
|
|
|
self.status_var = tk.StringVar(value="Desconectado")
|
|
ttk.Label(scan_frame, textvariable=self.status_var, foreground="#a33").grid(
|
|
row=0, column=4, sticky="e", padx=12
|
|
)
|
|
scan_frame.grid_columnconfigure(4, weight=1)
|
|
|
|
# ------------------------------------------------------ Tags table
|
|
tags_frame = ttk.LabelFrame(self, text="Tags detectadas")
|
|
tags_frame.pack(fill="both", expand=True, **pad)
|
|
|
|
columns = ("epc", "rssi", "count", "last_seen")
|
|
self.tree = ttk.Treeview(tags_frame, columns=columns, show="headings", height=10)
|
|
self.tree.heading("epc", text="EPC")
|
|
self.tree.heading("rssi", text="RSSI")
|
|
self.tree.heading("count", text="Leituras")
|
|
self.tree.heading("last_seen", text="Ultima leitura")
|
|
self.tree.column("epc", width=280)
|
|
self.tree.column("rssi", width=80, anchor="center")
|
|
self.tree.column("count", width=90, anchor="center")
|
|
self.tree.column("last_seen", width=160, anchor="center")
|
|
self.tree.pack(fill="both", expand=True, side="left", padx=(0, 4))
|
|
|
|
scrollbar = ttk.Scrollbar(tags_frame, orient="vertical", command=self.tree.yview)
|
|
self.tree.configure(yscroll=scrollbar.set)
|
|
scrollbar.pack(side="right", fill="y")
|
|
|
|
# ------------------------------------------------------ Log console
|
|
log_frame = ttk.LabelFrame(self, text="Log / quadros brutos (depuracao)")
|
|
log_frame.pack(fill="both", expand=False, **pad)
|
|
|
|
self.log_text = scrolledtext.ScrolledText(log_frame, height=8, state="disabled", font=("Consolas", 9))
|
|
self.log_text.pack(fill="both", expand=True, padx=4, pady=4)
|
|
|
|
# ------------------------------------------------------------- helpers
|
|
def _log(self, msg):
|
|
ts = datetime.now().strftime("%H:%M:%S")
|
|
self.log_text.configure(state="normal")
|
|
self.log_text.insert("end", f"[{ts}] {msg}\n")
|
|
self.log_text.see("end")
|
|
self.log_text.configure(state="disabled")
|
|
|
|
def _refresh_ports(self):
|
|
ports = []
|
|
if serial is not None:
|
|
ports = [p.device for p in serial.tools.list_ports.comports()]
|
|
self.port_combo["values"] = ports
|
|
if ports and not self.port_var.get():
|
|
self.port_var.set(ports[0])
|
|
self._log(f"Portas encontradas: {', '.join(ports) if ports else '(nenhuma)'}")
|
|
|
|
def _set_controls_connected(self, connected):
|
|
state_conn = "disabled" if connected else "readonly"
|
|
self.port_combo.configure(state=state_conn)
|
|
self.baud_combo.configure(state=state_conn)
|
|
self.connect_btn.configure(text="Desconectar" if connected else "Conectar")
|
|
self.set_power_btn.configure(state="normal" if connected else "disabled")
|
|
self.start_btn.configure(state="normal" if connected else "disabled")
|
|
self.single_btn.configure(state="normal" if connected else "disabled")
|
|
self.stop_btn.configure(state="disabled")
|
|
self.status_var.set("Conectado" if connected else "Desconectado")
|
|
|
|
# ------------------------------------------------------------ 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, 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._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(baud_source)
|
|
except (TypeError, ValueError):
|
|
if not silent:
|
|
messagebox.showwarning("Atencao", "Baudrate invalido.")
|
|
return
|
|
|
|
try:
|
|
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"
|
|
"Se o erro for 'Acesso negado'/PermissionError, verifique se a porta\n"
|
|
"nao esta aberta em outro programa (Monitor Serial da Arduino IDE,\n"
|
|
"outra instancia deste app, PuTTY, etc.) e tente novamente.",
|
|
)
|
|
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)
|
|
self.reader_thread.start()
|
|
|
|
self._set_controls_connected(True)
|
|
self._log(f"Conectado em {port} @ {baud} bps")
|
|
|
|
# consulta informacoes basicas do modulo ao conectar
|
|
self._send(frame_get_info(INFO_HW_VERSION))
|
|
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:
|
|
self.reader_thread.join(timeout=1.0)
|
|
if self.ser is not None:
|
|
with self._port_lock:
|
|
try:
|
|
self.ser.close()
|
|
except Exception:
|
|
pass
|
|
self.ser = None
|
|
self._set_controls_connected(False)
|
|
self._log("Desconectado")
|
|
|
|
def _on_close(self):
|
|
try:
|
|
self._disconnect()
|
|
finally:
|
|
self.destroy()
|
|
|
|
def _fatal_disconnect(self, reason):
|
|
"""
|
|
Encerra a conexao apos um erro irrecuperavel (ex.: PermissionError
|
|
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:
|
|
try:
|
|
self.ser.close()
|
|
except Exception:
|
|
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:
|
|
return
|
|
try:
|
|
with self._port_lock:
|
|
self.ser.write(frame)
|
|
except Exception as exc:
|
|
self._fatal_disconnect(f"Erro ao enviar: {exc}")
|
|
return
|
|
self._log(f"TX: {frame.hex(' ').upper()}")
|
|
|
|
def _reader_loop(self):
|
|
# Le em pequenos pedaços e mantem o lock so durante a chamada
|
|
# in_waiting+read (nao-bloqueante, pois so le o que ja esta
|
|
# disponivel), para nao competir por muito tempo com escritas
|
|
# feitas pela thread principal.
|
|
while not self.stop_event.is_set():
|
|
try:
|
|
with self._port_lock:
|
|
if self.ser is None:
|
|
break
|
|
waiting = self.ser.in_waiting
|
|
data = self.ser.read(waiting) if waiting else b""
|
|
except Exception as exc:
|
|
self.event_queue.put(("fatal", f"Erro de leitura serial: {exc}"))
|
|
break
|
|
|
|
if data:
|
|
frames = self.parser.feed(data)
|
|
for frame in frames:
|
|
self.event_queue.put(("frame", frame))
|
|
else:
|
|
time.sleep(0.02)
|
|
|
|
# ------------------------------------------------------------- actions
|
|
def _set_power(self):
|
|
try:
|
|
power = float(self.power_var.get())
|
|
except (tk.TclError, ValueError):
|
|
messagebox.showwarning("Atencao", "Valor de potencia invalido.")
|
|
return
|
|
self._send(frame_set_power(power))
|
|
self._log(f"Solicitada potencia de saida: {power:.1f} dBm")
|
|
|
|
def _single_poll(self):
|
|
self._send(frame_single_poll())
|
|
|
|
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")
|
|
self.stop_btn.configure(state="disabled")
|
|
if self.ser is not None:
|
|
self.status_var.set("Conectado")
|
|
if not silent:
|
|
self._log("Varredura interrompida")
|
|
|
|
def _clear_tags(self):
|
|
self.tags.clear()
|
|
for item in self.tree.get_children():
|
|
self.tree.delete(item)
|
|
|
|
# ---------------------------------------------------------- fila/eventos
|
|
def _poll_queue(self):
|
|
try:
|
|
while True:
|
|
kind, payload = self.event_queue.get_nowait()
|
|
if kind == "log":
|
|
self._log(payload)
|
|
elif kind == "frame":
|
|
self._handle_frame(payload)
|
|
elif kind == "fatal":
|
|
self._fatal_disconnect(payload)
|
|
except queue.Empty:
|
|
pass
|
|
finally:
|
|
self.after(100, self._poll_queue)
|
|
|
|
def _handle_frame(self, frame: bytes):
|
|
self._log(f"RX: {frame.hex(' ').upper()}")
|
|
try:
|
|
info = parse_frame(frame)
|
|
except Exception as exc:
|
|
self._log(f"Falha ao interpretar quadro: {exc}")
|
|
return
|
|
|
|
if info["kind"] == "tag":
|
|
self._register_tag(info)
|
|
elif info["kind"] == "power" and "power_dbm" in info:
|
|
self._log(f"Potencia reportada pelo modulo: {info['power_dbm']:.2f} dBm")
|
|
elif info["kind"] == "info":
|
|
self._log(f"Info do modulo (cmd 0x03): {info['info_text']}")
|
|
|
|
def _register_tag(self, info):
|
|
epc = info["epc"]
|
|
now = datetime.now().strftime("%H:%M:%S")
|
|
if epc in self.tags:
|
|
self.tags[epc]["count"] += 1
|
|
self.tags[epc]["rssi"] = info["rssi_dbm"]
|
|
self.tags[epc]["last_seen"] = now
|
|
self.tree.item(self.tags[epc]["item"], values=(epc, info["rssi_dbm"], self.tags[epc]["count"], now))
|
|
else:
|
|
item = self.tree.insert("", "end", values=(epc, info["rssi_dbm"], 1, now))
|
|
self.tags[epc] = {"count": 1, "rssi": info["rssi_dbm"], "last_seen": now, "item": item}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
app = RfidApp()
|
|
app.mainloop()
|