ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

通过Windows打印机驱动获取真实型号的工程实践

通过Windows打印机驱动获取真实型号的工程实践 做MIS、OA、进销存这类系统的老哥大概率都撞上过同一个需求桌面上要显示“当前可用打印机”和“打印机型号”。程序调PrintDialog倒是现成可一旦要拿型号存数据库、做设备台账、或者做打印管控不少人就卡住了——翻遍Windows打印API居然没有哪个函数直接告诉你“这台打印机叫LBP2900”还是“M1136”。更坑的是用户给打印机队列起的名字五花八门什么“财务室”“前台彩打”“测试机”都有队列名根本不能当型号用。我早年也在这个问题上栽过跟头后来花了两天把Windows打印驱动体系捋了一遍才真正搞明白要拿打印机型号本质上是拿“打印机队列关联的驱动信息”再从中提取型号。这篇文章就是把这条路线彻底拆开讲清楚——用什么API、按什么顺序调、代码怎么写、遇到通用驱动和虚拟打印怎么办全部给到。环境是老一点的VC/VS2008代码也能直接跑新版本的Visual Studio同样兼容。1. 核心思路打印队列、驱动名与真实型号的关系1.1 你可能一直搞错的对象打印队列不等于打印机Windows里的“打印机”从编程角度说其实是一个逻辑对象更准确的叫法是“打印队列”。它是打印后台处理程序Spooler里维护的一个条目包含名称、端口、驱动、默认属性等信息。这个对象对应某个物理设备但物理设备本身没有暴露给你。用户创建队列的时候队列名可以随便起。“AccLaser”“办公室那台大哥大”“test”这些名字都存在注册表和打印服务里但它们和硬件型号没有任何对应关系。这也是很多新手拿EnumPrinters枚举结果打印出一屏幕莫名其妙的名称之后就懵掉的原因。那什么信息接近型号答案是驱动名。装打印机驱动时厂商会注册一个驱动名称比如“HP LaserJet Pro M1136 MFP”“Canon LBP2900”“EPSON L805 Series”。绝大多数情况下驱动名就是按硬件型号命名的所以拿到驱动名型号基本也就出来了。注意我说的是“绝大多数”还有一部分是通用驱动和虚拟打印机下面单独讲。1.2 Windows打印链路里“型号”藏在哪一层整个打印链路大致是应用程序 - GDI/打印API - 打印机驱动 - 打印后台处理程序 - 端口监视器 - 物理设备。从开发者的视角看这个链路里有三个层次的信息可以拿一是队列逻辑名就是用户看到的打印机名可以随便改不能用。二是驱动名注册驱动时写入系统的名称多数时候和型号强相关这是本文的主攻方向。三是物理设备身份比如USB打印机的硬件IDUSBPRINT\HPLaserJetProM1136、网络打印机的SNMP OID或状态页信息这是另一条获取路径最准确但开发成本也最高。所以标题里说的“通过打印机驱动获取打印机型号”落到代码上就是三件事枚举系统中的打印队列找到目标队列打开队列对象读取它关联的驱动名再把驱动名规范化为可展示的型号。1.3 获取型号的三条可行技术路线我实际用下来有三条路线可选各有各的使用场景第一条是纯API快照法也就是本文主要展开的EnumPrinters枚举队列、OpenPrinter打开队列、GetPrinter取驱动名、GetPrinterDriver取驱动详细信息。这条路能覆盖80%以上的场景代码量小稳定性好不需要管理员权限适合普通桌面软件使用。第二条是文件与注册表溯源法读取驱动的数据文件比如GPD文件中的*ModelName字段或者直接翻注册表项HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Print\Printers\队列名下的PrinterDriverData。这条路适合驱动名是“HP Universal Printing PCL 6”这种通用名、信息不明确的时候但不同厂商格式不统一代码要适配。第三条是硬件与网络交叉法USB本地打印机用SetupAPI枚举设备接口拿硬件ID网络打印机用SNMP查询设备描述。这条路最准但要额外引入协议和API轻量应用一般不值得做。上面三条路不是互相替代是可以叠加的。我的建议是先把第一条做扎实遇到识别不了的再补第二条实在不行才上第三条。2. 工程准备VS2008下的编译环境与依赖2.1 需要的头文件、库与API家族写这个功能只需要Windows系统自带的开发环境组件不用引入任何第三方库。核心头文件是windows.h和winspool.h前者提供Win32基础类型和通用API后者声明了打印相关的函数和结构体。链接方面需要winspool.lib。VS2008里最省事的做法是在代码顶部加一行#pragma comment(lib, winspool.lib)这行代码等价于在项目属性里手动添加附加依赖项。好处是换机器、换工程时不用重新配置代码自己就把依赖带了。我习惯用这种方式省得同事clone代码之后编译报一堆链接错误。要用的核心API一共四个EnumPrinters枚举打印队列OpenPrinter打开指定队列句柄GetPrinter读取队列详细信息GetPrinterDriver读取驱动详细信息。这四个函数都在winspool.dll里囊括了整篇文章的核心逻辑。2.2 字符集选择对代码的影响VS2008时代项目默认字符集有两种选择多字节字符集MBCS和UnicodeUTF-16。这个选择直接影响API调用方式也埋着不少坑。打印相关的API都有A和W两个版本比如EnumPrintersA和EnumPrintersW。A版接收char*W版接收wchar_t*。如果项目设置的是Unicode代码里写EnumPrinters会被宏展开成EnumPrintersW写的是MBCS则展开成EnumPrintersA。我的建议是显式调用W版API不要在代码里依赖宏展开。原因是打印队列名、驱动名、端口名在中文Windows下全是宽字符W版API天然就是Unicode不会出现中文乱码。当时我在一台装了中文驱动名的机器上测试用A版API读“佳能 LBP2900”这类名称转换出来直接成了问号换成W版之后彻底干净。代码里的字符串一律用L宽字符字面量宽字符串容器用std::wstring。这样做的好处是编译选项怎么改都不会出问题代码行为可预期。2.3 权限与运行环境注意事项普通读取打印机信息和驱动信息不需要管理员权限。我在标准用户账户下实测过EnumPrinters、OpenPrinter、GetPrinter这些调用都能正常返回。但有一点要注意如果你的程序要打开的是“其他用户创建的共享打印机队列”或者目标队列位于远程机器上那就可能需要相应凭据。另外打印后台服务Print Spooler没启动的话所有打印API都会失败GetLastError常见的是RPC服务器不可用1722。这种情况在优化过服务的精简系统上偶尔出现代码里要做好错误提示而不是静默失败。还有一类隐蔽问题企业域环境下组策略和杀毒软件可能拦截对某些打印队列的OpenPrinter调用。遇到权限或策略问题先看错误码再查域策略不要一上来就怀疑代码逻辑。3. 代码实现按步骤拿到驱动中的型号3.1 第一步EnumPrinters枚举当前打印队列EnumPrinters的完整签名不用背重点是三个参数第一个是枚举标志第二个是名字参数传NULL即可第三个是信息级别。信息级别用2返回PRINTER_INFO_2结构够用且信息最全。先看枚举并提取关键字段的代码#include windows.h #include winspool.h #include vector #include string #include iostream #pragma comment(lib, winspool.lib) using std::vector; using std::wstring; struct MyPrinterInfo { wstring name; // 队列名 wstring port; // 端口名 wstring driver; // 驱动名 }; BOOL GetAllPrinters(vectorMyPrinterInfo printers) { DWORD dwNeeded 0, dwReturned 0; BOOL bOk ::EnumPrintersW( PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS, NULL, 2, NULL, 0, dwNeeded, dwReturned); if (dwNeeded 0) return FALSE; vectorBYTE buffer(dwNeeded 1024); bOk ::EnumPrintersW( PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS, NULL, 2, buffer[0], (DWORD)buffer.size(), dwNeeded, dwReturned); if (!bOk ::GetLastError() ERROR_INSUFFICIENT_BUFFER) { buffer.resize(dwNeeded); bOk ::EnumPrintersW( PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS, NULL, 2, buffer[0], dwNeeded, dwNeeded, dwReturned); } if (!bOk) return FALSE; PPRINTER_INFO_2W pInfo (PPRINTER_INFO_2W)buffer[0]; for (DWORD i 0; i dwReturned; i) { MyPrinterInfo item; if (pInfo[i].pPrinterName) item.name pInfo[i].pPrinterName; if (pInfo[i].pPortName) item.port pInfo[i].pPortName; else if (pInfo[i].pDriverName) item.driver pInfo[i].pDriverName; printers.push_back(item); } return TRUE; }注意这里的Buffer处理技巧。第一次调用传空缓冲区函数会返回需要的缓冲区大小到dwNeeded这是典型的“查询-分配-再调用”模式。缓冲区预留1024字节是经验值能避免第二次调用因为驱动列表实时变动而重新分配。PRINTER_INFO_2里的pPrinterName、pPortName这些指针指向缓冲区内部偏移不代表独立内存块。还有重要一点EnumPrintersW返回的结构体数组长度是dwReturned但dwNeeded是整个缓冲区需要的字节数。很多新手把dwReturned当缓冲区大小然后越界访问这是经典崩溃点。代码里遍历用的是dwReturned。3.2 第二步OpenPrinter GetPrinter提取驱动名有了队列名之后下一步就是打开队列并读取详细属性。这里用到OpenPrinter和GetPrinter核心代码如下BOOL GetDriverNameByPrinter(const wchar_t* printerName, wstring driverName) { HANDLE hPrinter NULL; if (!::OpenPrinterW((LPWSTR)printerName, hPrinter, NULL)) return FALSE; DWORD dwNeeded 0; ::GetPrinterW(hPrinter, 2, NULL, 0, dwNeeded); if (dwNeeded 0) { ::ClosePrinter(hPrinter); return FALSE; } vectorBYTE buffer(dwNeeded); if (!::GetPrinterW(hPrinter, 2, buffer[0], dwNeeded, dwNeeded)) { ::ClosePrinter(hPrinter); return FALSE; } PPRINTER_INFO_2W pInfo (PPRINTER_INFO_2W)buffer[0]; if (pInfo-pDriverName) { driverName pInfo-pDriverName; } ::ClosePrinter(hPrinter); return TRUE; }这段代码有两个细节值得说。第一个是OpenPrinter的第一个参数是LPWSTR但字符串内容不会被修改所以从wstring.c_str()转成LPWSTR时我用了const_cast或者直接(LPWSTR)强行转换。严格来说应该用可写缓冲区但Windows文档明确说了这个参数不会被修改实际项目中这样写没出过问题。第二个是GetPrinter也是两次调用模式第一次传NULL拿大小第二次传缓冲。这里有个容易犯的错在第一次调用之后没有检查GetLastError。实际上GetPrinter第一次调用会返回FALSE但只要dwNeeded被正确设置我们就可以认为请求有效。我代码里只判断dwNeeded 0这是正确做法。3.3 第三步GetPrinterDriver获取驱动详细信息拿到驱动名还不够。驱动名只是驱动的“申请名称”真正要确认型号最好再读一下驱动详细信息结构——DRIVER_INFO_2。里面最值得关注的是pName、pDriverPath和pDataFile。pDriverPath告诉驱动DLL或EXE的实际位置pDataFile对传统Type 3打印驱动来说是GPD/PPD数据文件路径这里面往往藏着官方型号字符串。代码实现如下BOOL GetDriverInfoByName(const wchar_t* printerName, DRIVER_INFO_2W di2) { HANDLE hPrinter NULL; if (!::OpenPrinterW((LPWSTR)printerName, hPrinter, NULL)) return FALSE; DWORD dwNeeded 0; ::GetPrinterDriverW(hPrinter, NULL, 2, NULL, 0, dwNeeded); if (dwNeeded 0) { ::ClosePrinter(hPrinter); return FALSE; } vectorBYTE buffer(dwNeeded); if (!::GetPrinterDriverW(hPrinter, NULL, 2, buffer[0], dwNeeded, dwNeeded)) { ::ClosePrinter(hPrinter); return FALSE; } di2 *(PDRIVER_INFO_2W)buffer[0]; ::ClosePrinter(hPrinter); return TRUE; }DRIVER_INFO_2W结构的成员定义大致是typedef struct _DRIVER_INFO_2W { DWORD cVersion; LPWSTR pName; LPWSTR pEnvironment; LPWSTR pDriverPath; LPWSTR pDataFile; LPWSTR pConfigFile; } DRIVER_INFO_2W, *PDRIVER_INFO_2W;我实际用下来的经验是pName基本等于前面GetPrinter拿到的驱动名pDataFile在很多老式驱动里是GPD文件打开之后搜“*ModelName”就能找到类似“*ModelName: HP LaserJet 1018”的字段。当然这只对Type 3驱动有效Windows 8之后流行的Type 4驱动就不是这个结构了。你还可以用EnumPrinterDrivers直接枚举系统中所有已安装驱动再按pName匹配。区别是GetPrinterDriver从当前队列对象出发获取的是该队列实际绑定的驱动版本更精准EnumPrinterDrivers则可以按环境过滤比如只看Windows x64版本的驱动。如果系统装了同一打印机的32位和64位两套驱动EnumPrinterDrivers会列出两条记录用GetPrinterDriver反而没这个问题。3.4 第四步把驱动名“翻译”成厂商型号拿到驱动名之后最后一步就是做型号解析。这一步没有标准API纯靠规则。我总结了一套比较实用的清洗策略。直接返回驱动名是最省事的方式因为驱动名和型号高度重合。但是像“Microsoft Print to PDF”“Generic / Text Only”“Fax”“XPS Document Writer”这些虚拟打印机还有“HP Universal Printing PCL 6”“RICOH PCL6 UniversalDriver”这类通用驱动直接返回驱动名就会误导用户。我的处理思路是先识别虚拟打印机再按品牌关键词定位型号起点最后清理多余词缀。代码大致如下wstring ExtractModelFromDriverName(const wstring driverName) { // 1. 虚拟打印机直接返回标记 static const wstring virtualNames[] { LMicrosoft Print to PDF, LMicrosoft XPS Document Writer, LFax, LGeneric / Text Only, LOneNote, LSnagit }; for (int i 0; i 6; i) { if (driverName.find(virtualNames[i]) ! wstring::npos) return L; } // 2. 按品牌词定位 static const wstring brandList[] { LHP, LHewlett-Packard, LCanon, LEPSON, LEpson, LRICOH, LBrother, LSamsung, LLexmark, LXerox, LFuji Xerox, LDELL, LDell, LLenovo, LKyocera, LKONICA MINOLTA, LPANTUM }; for (int i 0; i sizeof(brandList)/sizeof(brandList[0]); i) { size_t pos driverName.find(brandList[i]); if (pos ! wstring::npos) { wstring model driverName.substr(pos); // 3. 去掉常见无意义后缀 wstring suffix[] { L Driver, L v4, L V4, L Class Driver }; for (int j 0; j 4; j) { size_t sp model.find(suffix[j]); if (sp ! wstring::npos) model.erase(sp); } return model; } } // 4. 无品牌可识别返回原名 return driverName; }这个函数不是完美的但它覆盖了我在实际项目中遇过的绝大多数情况。补充几个判断经验放进一张表驱动名典型示例 | 解析结果 | 使用场景 HP LaserJet Pro M1136 MFP | HP LaserJet Pro M1136 MFP | 标准品牌驱动可放心展示 Canon LBP2900 | Canon LBP2900 | 品牌词在头部直接截取 HP Universal Printing PCL 6 | HP Universal Printing PCL 6 | 这是通用驱动不能当具体型号建议走兜底逻辑 Microsoft Print to PDF | 空 | 虚拟打印机直接过滤 PANTUM P2200 series | PANTUM P2200 | 品牌词在头部清理掉“series”之外的词尾4. 不是所有驱动都按规矩来通用驱动与特殊场景4.1 通用驱动与虚拟打印机的识别前面代码里已经过滤了虚拟打印机但通用驱动比虚拟打印机更棘手。“HP Universal Printing PCL 6”“RICOH PCL6 UniversalDriver”“Xerox Global Print Driver PCL6”这类驱动厂商为了统一管理所有型号共用一个驱动名。这种情况下驱动名只说明用了哪个驱动家族不能说明具体是哪台机器。遇到这种驱动我建议先尝试读队列的端口看看是USB还是网络。USB端口的话可以尝试通过SetupAPI拿硬件ID后面4.3节详述。网络端口的话可以尝试SNMP查询。如果这两个都不想做还有一条简单粗暴的路提示用户手动指定型号然后存到自己的配置表里。很多ERP、OA系统的设备台账就是这么干的——能自动识别就自动识别不了让管理员选一次后续都按本地映射表来。这里要强调一点千万不要把“驱动能打印”等同于“驱动里有型号信息”。通用驱动设计目的是跨型号兼容型号信息可能在设备端维护Windows这边根本没有统一存储。4.2 数据文件与注册表信息的补充读取当驱动名不干净时可以往深挖一层。Type 3驱动的数据文件里几乎都有型号信息最典型的是GPD文件里的*ModelName字段。通过DRIVER_INFO_2的pDataFile拿到路径后用普通的文件读取方式打开搜索关键字即可// 伪代码读GPD文件并查找ModelName std::ifstream file(pDataFilePath); while (!file.eof()) { std::string line; std::getline(file, line); if (line.find(*ModelName) ! std::string::npos) { // 提取其中的字符串 } }这个方法对HP、Canon的老驱动特别有效因为它们是Unidrv架构GPD文件里的ModelName就是硬件型号。新式Type 4驱动没有标准GPD文件这条路就走不通。另一个补充途径是注册表。打印机队列信息位于HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Print\Printers\队列名在这个键下面除了Driver、Port等标准值部分驱动会在PrinterDriverData子键里写入设备ID或型号字符串。比如HP的一些驱动会写一个名字类似“PrinterDeviceID”的值里面可能包含硬件型号。注册表方案的问题是没有统一标准——同一个键不同厂商的取值名完全不同甚至同一个厂商不同版本的驱动也不一样。我的实用建议是不要把它当主方案而是当兜底方案遇到某款驱动识别不了时先手动查一下它的注册表结构再把值名适配进代码里。4.3 通过网络端口和硬件ID获取型号的扩展方法如果你的打印机是USB连接想拿最准确的硬件型号可以用SetupAPI枚举打印机类设备。打印机设备类GUID是{4d36e979-e325-11ce-bfc1-08002be10318}USB打印机的硬件ID通常是“USBPRINT\VendorModelName”格式。下面给出思路#include setupapi.h #pragma comment(lib, setupapi.lib) // 枚举打印机类设备取SPDRP_HARDWAREID属性 HDEVINFO hDevInfo ::SetupDiGetClassDevs( GUID_DEVCLASS_PRINTER, NULL, NULL, DIGCF_PRESENT | DIGCF_ALLCLASSES);这里的GUID_DEVCLASS_PRINTER定义在devguid.h里。拿到硬件ID之后一般情况下USBPRINT\后面的字符串就是厂商型号标识。同样如果你装了多个USB打印机可以通过总线关系把硬件设备和打印队列端口关联起来但这已经超出“通过打印机驱动获取型号”的范围了属于设备拓扑分析复杂度要高一个量级。网络打印机的话还有一个成本不高但准确度不错的办法直接查SNMP。打印机标准MIB里有设备描述字段常见OID是1.3.6.1.2.1.25.3.2.1.3.1prtGeneralPrinterName以及1.3.6.1.2.1.1.1.0sysDescr。Windows自带snmpapi.lib可以发SNMP请求也可以自己用UDP发SNMP包解析。这种方法不依赖Windows驱动能拿到设备宣传的真实型号但前提是网络打印机开启了SNMP服务。4.4 获取型号失败的兜底策略无论怎么做总有拿不到型号的时候。我踩过不少坑之后总结出这么几个兜底策略按优先级排列第一降级展示。界面显示队列名并在后面加“未知型号”至少不会误导用户。第二建立本地映射表。把常见驱动名到型号的映射写进配置文件识别失败时查表。第三人工补充。在系统管理界面给管理员留一个“手动指定型号”的入口填完之后存到自己的配置表里。第四日志追踪。所有识别失败的事件都写日志方便客户现场反馈后远程诊断。这四个策略组合使用基本能覆盖所有场景。用户看到的永远是一个可读的、不报错的结果后台的自动识别率也会随着映射表的完善越来越高。5. 踩坑实录常见问题与排查速查表最后把这几年在这个功能上踩过的坑整理出来做成速查表。每一项都是我实际遇到并解决的不是网上抄的。现象排查方向解决办法EnumPrinters返回FALSEGetLastErrorERROR_INVALID_PARAMETER参数错误检查dwLevel必须为2或3缓冲区指针和大小不能同时为NULLGetPrinter返回ERROR_INVALID_PRINTER_NAME队列名不正确不要自己拼名字用EnumPrinters返回的pPrinterNameOpenPrinter成功但GetPrinter返回127ERROR_PROC_NOT_FOUND打印服务异常检查Print Spooler服务重启后重试中文驱动名显示为乱码字符集问题统一使用W版API和std::wstring驱动名是“Microsoft Print to PDF”虚拟打印机在展示层直接过滤识别出的驱动名和型号不一致通用驱动走SNMP、注册表或人工映射兜底程序是32位系统是64位驱动信息重定向编译x64版本或处理WOW64文件重定向杀毒软件或域策略拦截OpenPrinter策略限制查错误码ERROR_ACCESS_DENIED联系IT放行队列存在但OpenPrinter返回5拒绝访问权限不足确认是否访问了远程共享队列且无凭据GetPrinterDriver返回ERROR_INSUFFICIENT_BUFFER缓冲区不足按dwNeeded动态扩容后再调用关于Print Spooler服务我再多说一句。这个服务被禁用或停止时EnumPrinters本身就可能返回0且dwNeeded为0。代码里我在枚举前先判断dwNeeded如果等于0直接返回FALSE日志里记录“打印服务不可用”比弹一个不明不白的错误框友好得多。还有个容易忽略的细节同一台物理打印机可能在系统里存在多个队列比如一个USB、一个网络共享还可能有带“副本”后缀的重复队列。它们各自对应不同的queue但底层是同一台设备。这种时候按队列展示型号没问题但做设备去重统计时应该把端口和设备ID纳入判断而不是只看型号字符串。32位和64位的问题也值得单独强调。64位Windows上32位进程访问C:\Windows\System32\spool\drivers之类路径时会被WOW64重定向到SysWOW64。如果程序要用文件解析方式读取GPD数据文件拿到的路径可能被系统自动重定向导致打开失败。解决方法是代码里显式使用Wow64DisableWow64FsRedirection禁用重定向或者直接编译64位版本。现在Visual Studio默认都编译64位了老项目里我遇到过好几回这种夹生问题排查起来很费劲。另外还有一个我早期忽略的问题EnumPrinters返回的PRINTER_INFO_2结构中字符串指针指向的是缓冲区内部。如果直接把结构体拷贝到vector里到函数体外再访问指针就会变成野指针。这也是很多教程代码在“示例环境能跑、放工程里就崩”的常见原因。我文章里的代码在枚举时就把字符串提取成wstring再保存就是规避这个问题建议你也这么做。关于型号清洗再补充一点经验不要过度清理。有些驱动名带“series”“class”这样的词比如“PANTUM P2200 series”很多实现会直接把“series”删掉但保留“P2200”就足够了。真正需要清理的只有“ Driver”“ v4”这类明显是驱动结构描述的尾部以及“Universal”“Class Driver”这类泛用标识。清洗太狠容易把有效信息也删掉到时候用户看到的型号比驱动名还难认。我在这类需求上还有一个习惯把识别结果缓存起来。每次启动软件时先读一次打印机信息并把识别结果以“队列名驱动名识别型号”的形式落盘。这样用户拔掉打印机或者驱动临时出问题时软件界面依然能展示上次成功识别的型号不至于一遇到打印机离线就全空白。这个方案成本很低唯独要注意队列名可能被改所以缓存key用队列名端口名组合更稳妥。如果公司内部有网络打印机我建议优先整理一份“打印机IP-型号-备注”的静态配置表在软件里支持手工维护。因为网络打印机扫描时SNMP不一定开状态页解析也费时间静态表反而是最可靠、最省事的手段。等后期设备和IP多了再考虑做自动发现和映射完全没必要在一开始就把SNMP整个啃下来。这个功能后续如果想扩展还可以往这两个方向走一是加一个“打印测试页并读取状态页”的自动化流程让系统自己把报告抓回来解析二是通过Windows的打印机端口监视器写个监控模块统计每台打印机的真实打印量和故障频率。这些和“获取型号”同属打印管理领域代码基础都是本文这套API调用体系整体扩展成本不高。
返回列表