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

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

相关推荐
神奇的小猴程序员17 小时前
苹果Mac电脑玩我的世界没有版本?用PCL-R即可!
macos
灵韵aura17 小时前
macOS 的指令缩写
macos
yqj2342 天前
ESP32-S3中小智项目优化内容
ide·macos·xcode
Mininglamp_27183 天前
Cider量化配置与性能调优:在M系列Mac上压榨Mano-P的每一点推理性能
macos
xuefuhe3 天前
MacOS EAGAIN无法打开terminal
macos
传奇开心果编程3 天前
【SwiftUI入门练中学】第7课 动画与手势:让界面更灵动
学习·macos·ui·ios·swiftui·swift
the3clipse4 天前
MacOS、Linux 与 Windows 视频编解码对比:AV1、HEVC、ProRes 与硬件加速优劣势
linux·windows·macos·hevc·av1·视频解码·硬件解码
传奇开心果编程4 天前
【SwiftUI娓娓道来】第3课:让界面活起来——交互与动画指南
学习·macos·ui·ios·swiftui·swift
朋友圈自动点赞工具4 天前
Gmail 委托邮箱在手机上关不掉
macos·智能手机·cocoa·硬件评测
小黄人软件4 天前
AndroidStudio老项目 macOS运行BlueTooth蓝牙串口助手(Android+Studio源码).rar
android·macos