蓝牙 MAC 扫描匹配器

有个需求,就是找出xlsx表里面的蓝牙mac地址与sn号对应的信息:

1.详细过程: 电脑打开蓝牙扫描的功能,然后加载xlsx表,如果扫描到xlsx存在的mac地址,那么将对应的sn展示在程序gui上。 sn 号可以复制,也同时展示对应的条形码。

2.要求你电脑有蓝牙功能,否则无法使用。

以下代码在py3.8以上测试通过

python 复制代码
import asyncio
import threading
import tkinter as tk
from tkinter import filedialog, scrolledtext, messagebox
import pandas as pd
import re
import json
import os
from bleak import BleakScanner
import barcode
import pyperclip

try:
    from barcode.writer import ImageWriter
except ImportError:
    from barcode import writer

    ImageWriter = writer.ImageWriter
from PIL import Image, ImageTk

# ========== 全局变量 ==========
scanning = False
loop = None
CONFIG_FILE = "ble_scanner_config.json"
current_sn = None
g_sn = ""


# ========== 辅助函数 ==========
def normalize_mac(mac: str) -> str:
    if not isinstance(mac, str):
        return ""
    clean = re.sub(r'[^A-Fa-f0-9]', '', mac)
    return clean.upper()


def load_excel(file_path):
    try:
        df = pd.read_excel(file_path, dtype=str)
    except Exception as e:
        raise ValueError(f"读取文件失败: {e}")

    mac_col = None
    sn_col = None
    for col in df.columns:
        if 'mac' in col.lower():
            mac_col = col
        if 'sn' in col.lower() or '序列号' in col:
            sn_col = col
    if mac_col is None:
        raise ValueError("未找到包含 'MAC' 的列,请检查列名")
    if sn_col is None:
        raise ValueError("未找到包含 'SN' 或 '序列号' 的列,请检查列名")

    mac_sn_map = {}
    for idx, row in df.iterrows():
        mac_val = row[mac_col]
        sn_val = row[sn_col]
        if pd.notna(mac_val) and pd.notna(sn_val):
            norm_mac = normalize_mac(str(mac_val))
            if norm_mac:
                mac_sn_map[norm_mac] = str(sn_val)
    return mac_sn_map


def save_config(file_path):
    with open(CONFIG_FILE, "w") as f:
        json.dump({"excel_path": file_path}, f)


def load_config():
    if os.path.exists(CONFIG_FILE):
        with open(CONFIG_FILE, "r") as f:
            try:
                config = json.load(f)
                return config.get("excel_path", "")
            except:
                return ""
    return ""


# ========== 扫描逻辑 ==========
async def scan_ble_once(timeout=3):
    devices = await BleakScanner.discover(timeout=timeout, return_adv=True)
    result = {}
    for addr, (dev, adv_data) in devices.items():
        norm_mac = normalize_mac(addr)
        if norm_mac:
            rssi = adv_data.rssi if adv_data else -100
            result[norm_mac] = {
                'name': dev.name or '未知',
                'rssi': rssi,
                'addr': addr
            }
    return result


async def scan_and_match(gui_callback, rssi_low, rssi_high, mac_sn_map, stop_event):
    round_num = 0
    while not stop_event.is_set():
        round_num += 1
        gui_callback(f"🔄 第 {round_num} 轮扫描...")
        devices = await scan_ble_once(timeout=3)
        gui_callback(f"📡 发现 {len(devices)} 个设备")

        found_in_list = False
        for mac, info in devices.items():
            if mac in mac_sn_map:
                found_in_list = True
                rssi = info['rssi']
                sn = mac_sn_map[mac]
                if rssi_low <= rssi <= rssi_high:
                    gui_callback(f"✅ 找到匹配设备!MAC: {mac}, SN: {sn}, RSSI: {rssi} dBm")
                    return mac, sn, rssi
                else:
                    gui_callback(f"   MAC {mac} 信号 {rssi} dBm 不在范围内 (SN={sn})")
        if not found_in_list:
            gui_callback("   本轮未发现列表中的设备")

        await asyncio.sleep(1)
    return None, None, None


# ========== GUI 控制 ==========
def start_scan(gui_app):
    gui_app.text_log.delete('1.0', tk.END)
    gui_app.entry_result.delete(0, tk.END)
    global scanning
    if scanning:
        return
    scanning = True

    try:
        low = int(gui_app.entry_low.get())
        high = int(gui_app.entry_high.get())
        if low > high:
            messagebox.showerror("错误", "下限不能大于上限")
            scanning = False
            return
    except ValueError:
        messagebox.showerror("错误", "请输入有效的整数")
        scanning = False
        return

    if not gui_app.mac_sn_map:
        messagebox.showerror("错误", "请先加载 Excel 文件")
        scanning = False
        return

    gui_app.btn_start.config(state=tk.DISABLED)
    gui_app.btn_stop.config(state=tk.NORMAL)
    gui_app.btn_barcode.config(state=tk.DISABLED)
    gui_app.clear_log_btn.config(state=tk.NORMAL)
    gui_app.paste_sn_btn.config(state=tk.NORMAL)
    gui_app.clear_barcode_preview()
    gui_app.text_log.insert(tk.END, "🔍 开始扫描...\n")
    gui_app.text_log.see(tk.END)

    gui_app.stop_event = threading.Event()

    def run_async():
        global scanning, loop, current_sn
        loop = asyncio.new_event_loop()
        asyncio.set_event_loop(loop)
        try:
            result = loop.run_until_complete(
                scan_and_match(
                    gui_callback=gui_app.log_message,
                    rssi_low=low,
                    rssi_high=high,
                    mac_sn_map=gui_app.mac_sn_map,
                    stop_event=gui_app.stop_event
                )
            )
            mac, sn, rssi = result
            if mac:
                current_sn = sn
                global g_sn
                g_sn = sn
                gui_app.log_message(f"\n🎯 最终结果:MAC={mac}, SN={sn}, RSSI={rssi} dBm")
                gui_app.update_result(f"MAC: {mac}  |  SN: {sn}  |  RSSI: {rssi} dBm", 'green')
                gui_app.btn_barcode.config(state=tk.NORMAL)
            else:
                current_sn = None
                gui_app.log_message("\n⏹ 扫描已停止,未找到匹配设备。")
                gui_app.update_result("未找到匹配设备")
                gui_app.btn_barcode.config(state=tk.DISABLED)
        except Exception as e:
            gui_app.log_message(f"❌ 扫描异常: {e}")
            gui_app.btn_barcode.config(state=tk.DISABLED)
        finally:
            scanning = False
            gui_app.root.after(0, gui_app.scan_finished)

    thread = threading.Thread(target=run_async, daemon=True)
    thread.start()


def stop_scan(gui_app):
    global scanning
    if hasattr(gui_app, 'stop_event'):
        gui_app.stop_event.set()
        gui_app.log_message("\n⏹ 用户停止扫描...")
    gui_app.btn_start.config(state=tk.NORMAL)
    gui_app.btn_stop.config(state=tk.DISABLED)
    scanning = False


def clear_log(gui_app):
    gui_app.text_log.delete('1.0', tk.END)


def paste_sn():
    global g_sn
    # 复制文本到剪贴板
    pyperclip.copy(g_sn)
    if(len(g_sn)>0):
        messagebox.showinfo("已复制sn", g_sn)


# ========== GUI 主类 ==========
class BluetoothScannerApp:
    def __init__(self, root):
        self.root = root
        root.title("蓝牙 MAC 扫描匹配器")
        root.geometry("900x600")

        # 定义条码保存目录(程序所在目录下的 barcodes 文件夹)
        self.barcode_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "barcodes")
        if not os.path.exists(self.barcode_dir):
            os.makedirs(self.barcode_dir)

        self.mac_sn_map = {}
        self.stop_event = None

        # 整体左右分栏
        main_frame = tk.Frame(root)
        main_frame.pack(fill=tk.BOTH, expand=True, padx=20, pady=20)
        main_frame.grid_columnconfigure(0, weight=1)  # 左侧
        main_frame.grid_columnconfigure(1, weight=1)  # 中间
        main_frame.grid_columnconfigure(2, weight=1)  # 右侧(预览更宽)
        main_frame.grid_rowconfigure(0, weight=1)

        # ===== 左侧面板 =====
        left_frame = tk.Frame(main_frame)
        left_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 10))
        left_frame.grid_rowconfigure(0, minsize=30)  # 日志区占据剩余高度
        left_frame.grid_rowconfigure(1, minsize=50)  # 日志区占据剩余高度
        left_frame.grid_rowconfigure(2, minsize=300)  # 日志区占据剩余高度
        left_frame.grid_rowconfigure(3, weight=1)  # 日志区占据剩余高度
        left_frame.grid_columnconfigure(0, weight=1)

        # 1. 文件选择
        frame_file = tk.Frame(left_frame)
        frame_file.grid(row=0, column=0, sticky="ew", pady=(0, 5))
        tk.Label(frame_file, text="Excel 文件:").pack(side=tk.LEFT)
        self.file_path_var = tk.StringVar()
        tk.Entry(frame_file, textvariable=self.file_path_var, width=40).pack(side=tk.LEFT, padx=5)
        tk.Button(frame_file, text="浏览...", command=self.select_file, width=15).pack(side=tk.LEFT)

        # 2. RSSI 输入(单独一行)
        frame_rssi = tk.Frame(left_frame)
        frame_rssi.grid(row=1, column=0, sticky="ew", pady=(0, 5))
        tk.Label(frame_rssi, text="RSSI 下限 (dBm):").pack(side=tk.LEFT)
        self.entry_low = tk.Entry(frame_rssi, width=8)
        self.entry_low.pack(side=tk.LEFT, padx=5)
        self.entry_low.insert(0, "-80")
        tk.Label(frame_rssi, text="上限:").pack(side=tk.LEFT, padx=(10, 0))
        self.entry_high = tk.Entry(frame_rssi, width=8)
        self.entry_high.pack(side=tk.LEFT, padx=5)
        self.entry_high.insert(0, "-30")

        self.preview_label = tk.Label(left_frame, text="条码预览区域", font=("Arial", 12),
                                      bg="white", anchor="center")
        self.preview_label.grid(row=2, column=0, sticky="nsew", padx=0, pady=10)
        self.preview_image = None
        self.original_barcode_image = None

        # ===== 右侧面板(条码预览) =====
        middle_frame = tk.Frame(main_frame, width=80)
        middle_frame.grid(row=0, column=1, sticky="nsew", padx=(20, 0))
        middle_frame.grid_rowconfigure(0, minsize=100)
        middle_frame.grid_rowconfigure(1, minsize=100)
        middle_frame.grid_rowconfigure(2, minsize=100)
        middle_frame.grid_rowconfigure(3, minsize=100)
        middle_frame.grid_rowconfigure(4, weight=1)
        middle_frame.grid_columnconfigure(0, weight=1)

        # 3. 蓝色结果显示框
        self.entry_result = tk.Entry(middle_frame, font=("Arial", 12, "bold"), fg="blue",
                                     bg="white", relief="flat", state="readonly", width=50, justify="left")
        self.entry_result.grid(row=0, column=2, sticky="ew", pady=(0, 5))
        self.entry_result.insert(0, "等待扫描...")
        self.entry_result.config(state='readonly')

        # 按钮单独一行
        frame_clear_buttons = tk.Frame(middle_frame)
        frame_clear_buttons.grid(row=1, column=2, sticky="ew", pady=(0, 15))
        self.clear_log_btn = tk.Button(frame_clear_buttons, text="清除日志", command=lambda: clear_log(self),
                                       width=15)
        self.clear_log_btn.pack(side=tk.LEFT, padx=15)

        # 三个按钮单独一行
        frame_buttons = tk.Frame(middle_frame)
        frame_buttons.grid(row=2, column=2, sticky="ew", pady=(0, 15))
        self.btn_start = tk.Button(frame_buttons, text="开始扫描", command=lambda: start_scan(self), width=15)
        self.btn_start.pack(side=tk.LEFT, padx=15)
        self.btn_stop = tk.Button(frame_buttons, text="停止", command=lambda: stop_scan(self), state=tk.DISABLED,
                                  width=15)
        self.btn_stop.pack(side=tk.LEFT, padx=15)

        # 三个按钮单独一行
        frame_sn_buttons = tk.Frame(middle_frame)
        frame_sn_buttons.grid(row=3, column=2, sticky="ew", pady=(0, 15))
        self.btn_barcode = tk.Button(frame_sn_buttons, text="生成条码 (SN)", command=self.generate_barcode,
                                     state=tk.DISABLED, width=15, bg="lightgreen")
        self.btn_barcode.pack(side=tk.LEFT, padx=15)
        self.paste_sn_btn = tk.Button(frame_sn_buttons, text="复制SN", command=lambda: paste_sn(), width=15)
        self.paste_sn_btn.pack(side=tk.LEFT, padx=15)

        # ===== 右侧面板(条码预览) =====
        right_frame = tk.Frame(main_frame)
        right_frame.grid(row=0, column=2, sticky="nsew", padx=(0, 0))
        right_frame.grid_rowconfigure(0, minsize=400)
        right_frame.grid_rowconfigure(1, weight=1)
        right_frame.grid_columnconfigure(0, weight=1)
        right_frame.grid_columnconfigure(1, weight=1)

        # 5. 日志框(占据剩余高度)
        self.text_log = scrolledtext.ScrolledText(right_frame, width=40, height=50, state='normal')
        self.text_log.grid(row=0, column=2, sticky="nsew", padx=10, pady=10)
        self.text_log.config(font=("Consolas", 9))

        # 自动加载上次配置
        self.load_last_config()

    def update_result(self, text, color='blue'):
        self.entry_result.config(state='normal', foreground=color)
        self.entry_result.delete(0, tk.END)
        self.entry_result.insert(0, text)
        self.entry_result.config(state='readonly')

    def clear_barcode_preview(self):
        self.preview_label.config(image='', text="条码预览区域")
        self.preview_image = None
        self.original_barcode_image = None

    def generate_barcode(self):
        global current_sn
        if not current_sn:
            messagebox.showwarning("警告", "没有可用的 SN,请先扫描匹配设备")
            return

        try:
            code128 = barcode.get_barcode_class('code128')

            # 指定系统字体路径(Windows 常见字体)
            font_path = "C:/Windows/Fonts/arial.ttf"
            # 如果上述路径不存在,可尝试备用
            if not os.path.exists(font_path):
                font_path = "C:/Windows/Fonts/msyh.ttc"  # 微软雅黑

            writer_options = {
                'module_width': 0.3,
                'module_height': 20,
                'font_size': 12,
                'text_distance': 5,
                'quiet_zone': 6.5,
                'font_path': font_path  # 关键:指定字体
            }
            barcode_obj = code128(current_sn, writer=ImageWriter())
            pil_img = barcode_obj.render(writer_options=writer_options)
            self.original_barcode_image = pil_img

            # 预览宽度设为 300
            base_width = 300
            w_percent = base_width / float(pil_img.size[0])
            h_size = int(float(pil_img.size[1]) * w_percent)
            pil_img_resized = pil_img.resize((base_width, h_size), Image.Resampling.LANCZOS)
            self.preview_image = ImageTk.PhotoImage(pil_img_resized)
            self.preview_label.config(image=self.preview_image, text="")
        except Exception as e:
            # 将详细错误信息输出到日志
            import traceback
            error_msg = traceback.format_exc()
            self.log_message(f"❌ 生成条码预览失败:{error_msg}")
            messagebox.showerror("错误", f"生成条码预览失败,请查看日志。\n{e}")
            return

        # 自动保存
        filename = f"SN_{current_sn}.png"
        save_path = os.path.join(self.barcode_dir, filename)
        try:
            self.original_barcode_image.save(save_path)
            self.log_message(f"✅ 条码已保存到:{save_path}")
        except Exception as e:
            messagebox.showerror("错误", f"保存条码文件失败:{e}")

    def load_last_config(self):
        path = load_config()
        if path and os.path.exists(path):
            self.file_path_var.set(path)
            try:
                self.mac_sn_map = load_excel(path)
                self.log_message(f"✅ 自动加载上次文件,共 {len(self.mac_sn_map)} 条记录")
                self.update_result(f"已加载 {len(self.mac_sn_map)} 个设备")
            except Exception as e:
                self.log_message(f"⚠️ 上次文件加载失败: {e}")
                self.mac_sn_map = {}

    def select_file(self):
        file_path = filedialog.askopenfilename(
            title="选择 Excel 文件",
            filetypes=[("Excel 文件", "*.xlsx *.xls"), ("所有文件", "*.*")]
        )
        if file_path:
            self.file_path_var.set(file_path)
            try:
                self.mac_sn_map = load_excel(file_path)
                self.log_message(f"✅ 成功加载文件,共 {len(self.mac_sn_map)} 条记录")
                self.update_result(f"已加载 {len(self.mac_sn_map)} 个设备")
                save_config(file_path)
                self.clear_barcode_preview()
            except Exception as e:
                messagebox.showerror("加载错误", str(e))
                self.mac_sn_map = {}

    def log_message(self, msg):
        self.text_log.insert(tk.END, msg + "\n")
        self.text_log.see(tk.END)

    def scan_finished(self):
        if self.btn_stop['state'] == tk.NORMAL:
            self.btn_start.config(state=tk.NORMAL)
            self.btn_stop.config(state=tk.DISABLED)


if __name__ == "__main__":
    root = tk.Tk()
    app = BluetoothScannerApp(root)
    root.mainloop()

以上代码均为原创,请勿转载,请联系我删除!!!

相关推荐
刘婉晴4 小时前
【漏扫工具篇】Mac 系统上 Xray 的安装与使用
macos
寒水馨21 小时前
macOS下载、安装PowerShell-v7.6.4(附安装包powershell-7.6.4-osx-arm64.pkg)
macos·自动化·跨平台·shell·脚本·命令行·powershell
星辰即远方2 天前
天气预报总结
macos·ios·objective-c·cocoa
xixiaoyunya2 天前
Mac远程访问新范式:基于80KM穿云箭的内网穿透技术实践
macos
秋雨梧桐叶落莳2 天前
计算器仿写总结
学习·macos·ios·objective-c·cocoa
寒水馨2 天前
macOS下载、安装uv-0.12.0(附安装包uv-aarch64-apple-darwin.tar.gz)
python·macos·rust·项目管理·包管理器·astral·pip替代
寒水馨2 天前
macOS下载、安装protobuf-v35.1(附安装包protoc-35.1-osx-universal_binary.zip)
macos·google·grpc·序列化·protobuf·protoc·数据交换
寒水馨2 天前
macOS下载、安装ripgrep-15.2.0(附安装包ripgrep-15.2.0-aarch64-apple-darwin.tar.gz)
macos·正则表达式·rust·ripgrep·rg·命令行搜索·代码搜索
起司喵喵3 天前
Flutter-MacOS桌面OS系统|flutter.+window_manager客户端OS模板
flutter·macos·策略模式