蓝牙 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()

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

相关推荐
demo007x8 小时前
让大模型活在你的鼠标旁:我用 Tauri 2 + Rust 打造了一款“反直觉”的 AI 全局划词效率神器
macos·程序员·llm
那年窗外下的雪.15 小时前
AIDC 学习日志|第 24 天|设备输出反推与 MAC Flapping 定位
网络协议·学习·tcp/ip·http·macos·tcpdump
RobinDevNotes16 小时前
Palmier Pro:AI时代的Mac视频编辑器
人工智能·ceph·macos·ai·音视频·视频编辑·mcp
johnsong1 天前
AI前沿日报 2026-09-08
人工智能·macos
33三 三like2 天前
基于 MAC-RUNGUIDE 的完整启动流程
macos
tedcloud1232 天前
Wand-Enhancer:如何搭建一套远程开发与测试环境
前端·人工智能·macos·开源·流程图
A13345553 天前
Windows/Mac/电脑播放器推荐:全平台怎么选
macos·电脑
云边有个稻草人3 天前
飞牛 NAS 远程访问实战:星空组网连接 Mac 与安卓,从 Compose 部署到 5G 验证
android·5g·macos
for_ever_love__3 天前
iOS: GCD高级API
macos·ios·objective-c·cocoa·多线程·gcd
深念Y3 天前
黑苹果P400-卡顿排查与优化记录
linux·macos·ios·pve·黑苹果·英伟达·n卡