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

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

相关推荐
2601_9679791212 分钟前
Opteryx Python库 macOS版本0.0.0a12
python·macos·1024程序员节·wheel·opteryx·alpha
Khalil三省17 小时前
MacBook 刘海显示歌词:让 Mac 也有灵动岛歌词
macos·开源·效率工具·音乐
桌管小李21 小时前
终端安全一体化实战:AI 审计、行为分析、零信任、防拍照与远程运维全解析
运维·服务器·网络·人工智能·windows·安全·macos
Piink1 天前
Mac使用Vscode教程
ide·vscode·macos
福大大架构师每日一题1 天前
rust 1.99.1 正式更新:聚焦 UEFI、嵌入式、x86_64 FFI 与 macOS 链接体验的三项修复
开发语言·macos·rust
雪域迷影1 天前
MacOS中杀掉某个端口占用的进程
macos·端口·强杀进程
ManageEngine卓豪2 天前
企业 Mac终端如何统一管控?配置、补丁与应用的落地框架
macos·mdm·uem·终端管理系统
黑化旺仔2 天前
【OC】Runtime:类元数据、加载初始化与消息派发
开发语言·macos·ios·objective-c
Web3&Basketball3 天前
Meta Muse 816B 实测:两台 Mac 本地跑通
macos·大模型·mac·本地部署·moe·显存优化·meta muse
DevLoom_3 天前
macOS 27 升级翻车怎么降级?不用终端,2 种方案实测回退旧系统
macos·macos27·mac系统降级·macos安装盘·mac使用技巧