用 ESP32-S3 把舊 USB 印表機變成網路印表機

用 ESP32-S3 的 USB OTG 功能,將舊 USB 印表機橋接到 WiFi 網路,透過 RAW Port 9100 協定實現無線列印。電腦端只需新增標準 TCP/IP 印表機,完全不需要改變原有驅動程式設定。

用 ESP32-S3 把舊 USB 印表機變成網路印表機

你家有一台舊印表機,只有 USB 接口,每次要印東西都要拔線插線?這篇文章教你用一片 ESP32-S3 開發板,花不到幾百元,讓它變成網路印表機,電腦、筆電通通可以無線列印,再也不用走到桌邊插線。


原理說明

整個架構非常單純,ESP32-S3 扮演「橋接器」的角色:

電腦 / 筆電 → WiFi → ESP32-S3 → USB OTG → 印表機

電腦端透過網路把列印資料送到 ESP32-S3,ESP32-S3 再透過 USB Host 模式把資料轉發給印表機。對電腦來說,就像是在用一台普通的網路印表機,完全感覺不到中間有個小晶片在橋接。

為什麼用 RAW Port 9100

網路列印有幾種協定,這裡選擇最簡單的 RAW(Port 9100),原因有三:

  • ESP32 不需要解析任何列印格式,直接透明轉發
  • Windows、macOS、Linux 全部原生支援,不需要安裝額外軟體
  • 列印資料的格式轉換(PCL、ESC/P 等)由電腦端的驅動程式負責,ESP32 只是管道

這個方案適合哪些印表機

只要印表機符合以下條件就能用:

  • 有 USB 接頭
  • 屬於標準 USB Printer Class(Class Code 0x07),插上後電腦需要安裝驅動的印表機幾乎都是

不適合的情況:

  • 印表機本身已有 WiFi 功能(直接用原廠無線功能就好,不需要 ESP32)
  • 使用完全私有 USB 協定的特殊工業印表機

材料準備

項目 說明
ESP32-S3 開發板(雙 USB) 需要有兩個 USB 接口的款式
USB OTG 轉接線 Type-A 母頭 → Type-C 公頭(或視板子接口而定)
USB 印表機 你現有的印表機
ESP32-S3 N16R8 開發板 (16MB Flash / 8MB PSRAM)
傑森創工,專注於Arduino、ESP32、樹莓派(Raspberry Pi)、物聯網、創客(Maker)相關商品的研究,專業銷售Arduino材料、Arduino教材、各種電子材料、開發板、Arduino套件、感測器模組,以及各類工具。更提供許多獨家的專題套件,供大學或高中職學生製作Arduino、ESP32專題。最專業的Arduino、ESP32供應商。

關於 ESP32-S3 開發板的選擇

一定要選雙 USB 款,原因如下:

USB-C 燒錄接頭  ← 連接電腦燒錄程式,也用來供電
USB OTG 接頭   ← 連接印表機(這個接頭切換成 Host 模式驅動印表機)

兩個 USB 接頭各司其職,缺一不可。常見的雙 USB S3 開發板如 ESP32-S3等都適合。


開發環境設定

尚未用過ESP32-s3的朋友,建議可以先看我們之前的教學哦!

ESP32-S3 N16R8 開發板介紹
傑森創工嚴選:ESP32-S3 N16R8 頂規開發板!搭載 16MB Flash 與 8MB 高速 PSRAM,完美突破記憶體瓶頸。專為複雜圖形 UI、AI 視覺與高階 IoT 專案打造,全面升級您的硬體效能!

關鍵的 Arduino IDE 設定

這步是最容易出錯的地方,USB Mode 設定錯誤會導致 USB Host 完全無法運作,請照表設定:

設定項目 正確值 說明
Board ESP32S3 Dev Module
USB Mode USB-OTG (TinyUSB) 最關鍵,預設值是錯的
USB CDC On Boot Disabled 避免與 OTG 模式衝突
Partition Scheme Default 4MB 或 Huge APP
Upload Speed 921600 加速燒錄

接線方式

充電器 / 電腦
     ↓
[ESP32-S3 燒錄接頭 USB-C]   ← 供電用

印表機 USB 線
     ↓
[USB OTG 轉接頭(Type-A 母)]
     ↓
[ESP32-S3 OTG 接頭]         ← GPIO 19 (D−) / GPIO 20 (D+)

實際使用時,ESP32-S3 接線供電,印表機透過 OTG 轉接頭插到 OTG 口,兩條線分開,互不干擾。


程式碼

修改 WiFi 設定

下載程式碼後,只需要修改最頂部的兩行:

const char* WIFI_SSID     = "你的WiFi名稱";
const char* WIFI_PASSWORD = "你的WiFi密碼";

其他設定保持預設即可,或依需求調整:

const char* PRINTER_NAME  = "ESP32-Printer";  // 出現在網路上的名稱,可自訂
const int   PRINT_PORT    = 9100;             // RAW 列印 Port,建議不要更動

完整程式碼

/**
 * ESP32-S3 USB OTG 網路列印機橋接器
 * 傑森創工獨家開發
 * www.jmaker.com.tw
 *
 * 架構:電腦 → WiFi → TCP Port 9100 → ESP32-S3 → USB → 列印機
 *
 * Arduino IDE 設定:
 *   Board: ESP32S3 Dev Module
 *   USB Mode: USB-OTG (TinyUSB)      ← 最重要
 *   USB CDC On Boot: Disabled
 *   Partition Scheme: Default 4MB
 */

#include <WiFi.h>
#include <ESPmDNS.h>
#include "usb/usb_host.h"
#include "usb/usb_types_ch9.h"

// ============================================================
// 設定區:只需要改這裡
// ============================================================
const char* WIFI_SSID     = "你的WiFi名稱";
const char* WIFI_PASSWORD = "你的WiFi密碼";
const char* PRINTER_NAME  = "ESP32-Printer";
const int   PRINT_PORT    = 9100;
// ============================================================

WiFiServer printServer(PRINT_PORT);

static usb_host_client_handle_t clientHandle  = NULL;
static usb_device_handle_t      deviceHandle  = NULL;
static uint8_t                  bulkOutEpAddr = 0;
static uint8_t                  bulkOutEpMps  = 64;
static bool                     printerReady  = false;

#define RECV_BUF_SIZE 4096
static uint8_t recvBuf[RECV_BUF_SIZE];

// USB Host 事件回呼:處理印表機插入與拔除
static void usbHostClientCallback(const usb_host_client_event_msg_t* eventMsg, void* arg) {
    if (eventMsg->event == USB_HOST_CLIENT_EVENT_NEW_DEV) {
        Serial.printf("[USB] 偵測到新裝置,位址: %d\n", eventMsg->new_dev.address);

        esp_err_t err = usb_host_device_open(clientHandle, eventMsg->new_dev.address, &deviceHandle);
        if (err != ESP_OK) {
            Serial.printf("[USB] 開啟裝置失敗: %s\n", esp_err_to_name(err));
            return;
        }

        // 印出裝置 VID/PID,方便識別印表機型號
        const usb_device_desc_t* devDesc;
        usb_host_get_device_descriptor(deviceHandle, &devDesc);
        Serial.printf("[USB] VID: %04X  PID: %04X\n", devDesc->idVendor, devDesc->idProduct);

        // 掃描描述符,找 Bulk OUT 端點
        const usb_config_desc_t* cfgDesc;
        usb_host_get_active_config_descriptor(deviceHandle, &cfgDesc);

        int offset = 0;
        while (offset < cfgDesc->wTotalLength) {
            const usb_standard_desc_t* desc =
                (const usb_standard_desc_t*)((uint8_t*)cfgDesc + offset);
            if (desc->bLength == 0) break;

            if (desc->bDescriptorType == USB_B_DESCRIPTOR_TYPE_INTERFACE) {
                const usb_intf_desc_t* id = (const usb_intf_desc_t*)desc;
                Serial.printf("[USB] 介面 Class: 0x%02X SubClass: 0x%02X\n",
                              id->bInterfaceClass, id->bInterfaceSubClass);
                // 0x07 = USB Printer Class,看到這個代表是標準列印機
            }
            if (desc->bDescriptorType == USB_B_DESCRIPTOR_TYPE_ENDPOINT) {
                const usb_ep_desc_t* ep = (const usb_ep_desc_t*)desc;
                bool isBulk = (ep->bmAttributes & 0x03) == USB_TRANSFER_TYPE_BULK;
                bool isOut  = (ep->bEndpointAddress & 0x80) == 0;
                if (isBulk && isOut) {
                    bulkOutEpAddr = ep->bEndpointAddress;
                    bulkOutEpMps  = ep->wMaxPacketSize;
                    Serial.printf("[USB] Bulk OUT: 0x%02X  MPS: %d\n", bulkOutEpAddr, bulkOutEpMps);
                }
            }
            offset += desc->bLength;
        }

        if (bulkOutEpAddr == 0) {
            Serial.println("[USB] 找不到 Bulk OUT 端點,可能不是標準列印機");
            return;
        }

        err = usb_host_interface_claim(clientHandle, deviceHandle, 0, 0);
        if (err != ESP_OK) {
            Serial.printf("[USB] 認領介面失敗: %s\n", esp_err_to_name(err));
            return;
        }

        printerReady = true;
        Serial.println("[USB] 列印機就緒!");

    } else if (eventMsg->event == USB_HOST_CLIENT_EVENT_DEV_GONE) {
        Serial.println("[USB] 裝置已移除");
        printerReady  = false;
        bulkOutEpAddr = 0;
        if (deviceHandle) {
            usb_host_interface_release(clientHandle, deviceHandle, 0);
            usb_host_device_close(clientHandle, deviceHandle);
            deviceHandle = NULL;
        }
    }
}

// 透過 USB Bulk Transfer 把資料送給印表機
// 每次最多 512 bytes,送完等 10ms 讓印表機消化
bool sendToPrinter(const uint8_t* data, size_t len) {
    if (!printerReady || deviceHandle == NULL || bulkOutEpAddr == 0) {
        Serial.println("[USB] 列印機未就緒");
        return false;
    }

    size_t sent = 0;
    while (sent < len) {
        size_t chunk = min((size_t)512, len - sent);

        usb_transfer_t* t = NULL;
        if (usb_host_transfer_alloc(chunk, 0, &t) != ESP_OK) {
            Serial.println("[USB] 分配 transfer 失敗");
            return false;
        }

        memcpy(t->data_buffer, data + sent, chunk);
        t->num_bytes        = chunk;
        t->device_handle    = deviceHandle;
        t->bEndpointAddress = bulkOutEpAddr;
        t->timeout_ms       = 5000;
        t->context          = NULL;
        t->callback         = [](usb_transfer_t* x) {
            if (x->status != USB_TRANSFER_STATUS_COMPLETED)
                Serial.printf("[USB] Transfer 錯誤: %d\n", x->status);
            usb_host_transfer_free(x);
        };

        if (usb_host_transfer_submit(t) != ESP_OK) {
            Serial.println("[USB] 傳送失敗");
            usb_host_transfer_free(t);
            return false;
        }

        sent += chunk;
        vTaskDelay(pdMS_TO_TICKS(10));
    }

    Serial.printf("[USB] 已送出 %d bytes\n", len);
    return true;
}

// USB Host 背景任務,固定跑在 Core 0,不干擾 WiFi(Core 1)
void usbHostTask(void* arg) {
    usb_host_config_t hcfg = {
        .skip_phy_setup = false,
        .intr_flags     = ESP_INTR_FLAG_LEVEL1,
    };
    ESP_ERROR_CHECK(usb_host_install(&hcfg));
    Serial.println("[USB] Host 已初始化");

    usb_host_client_config_t ccfg = {
        .is_synchronous    = false,
        .max_num_event_msg = 5,
        .async = {
            .client_event_callback = usbHostClientCallback,
            .callback_arg          = NULL,
        }
    };
    ESP_ERROR_CHECK(usb_host_client_register(&ccfg, &clientHandle));
    Serial.println("[USB] 等待裝置插入...");

    while (true) {
        usb_host_lib_handle_events(pdMS_TO_TICKS(10), NULL);
        usb_host_client_handle_events(clientHandle, pdMS_TO_TICKS(10));
    }

    usb_host_client_deregister(clientHandle);
    usb_host_uninstall();
    vTaskDelete(NULL);
}

void setup() {
    Serial.begin(115200);
    delay(1000);
    Serial.println("\n=== ESP32-S3 網路列印機橋接器 ===");

    Serial.printf("[WiFi] 連接到 %s ...\n", WIFI_SSID);
    WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
    while (WiFi.status() != WL_CONNECTED) {
        delay(500);
        Serial.print(".");
    }
    Serial.printf("\n[WiFi] 已連線!IP: %s\n", WiFi.localIP().toString().c_str());

    // mDNS 廣播,部分系統可自動發現印表機
    if (MDNS.begin(PRINTER_NAME)) {
        MDNS.addService("pdl-datastream", "tcp", PRINT_PORT);
        Serial.printf("[mDNS] 廣播名稱: %s.local\n", PRINTER_NAME);
    }

    printServer.begin();
    Serial.printf("[TCP] 列印 Server 啟動,Port: %d\n", PRINT_PORT);
    Serial.println("-----------------------------------");
    Serial.printf("  Windows: 標準TCP/IP → IP: %s  Port: 9100\n",
                  WiFi.localIP().toString().c_str());
    Serial.printf("  Mac:     IP → HP Jetdirect-Socket → %s\n",
                  WiFi.localIP().toString().c_str());
    Serial.println("-----------------------------------");

    // USB Host 任務固定跑在 Core 0,優先權 5
    xTaskCreatePinnedToCore(usbHostTask, "usb_host", 4096, NULL, 5, NULL, 0);
}

void loop() {
    WiFiClient client = printServer.available();
    if (!client) return;

    Serial.printf("[TCP] 收到列印連線,來自: %s\n", client.remoteIP().toString().c_str());

    if (!printerReady) {
        Serial.println("[!] 列印機未就緒,拒絕連線");
        client.stop();
        return;
    }

    size_t totalBytes = 0;
    bool transferOk = true;

    while (client.connected()) {
        int available = client.available();
        if (available > 0) {
            int n = client.read(recvBuf, min(available, RECV_BUF_SIZE));
            if (n > 0) {
                if (transferOk) {
                    if (!sendToPrinter(recvBuf, n)) {
                        transferOk = false;
                        Serial.println("[!] USB 傳輸失敗,後續資料略過");
                    }
                }
                totalBytes += n;
            }
        } else {
            delay(5);
        }
    }

    client.stop();
    Serial.printf("[TCP] 完成,共 %d bytes,USB 傳輸: %s\n",
                  totalBytes, transferOk ? "成功" : "失敗");
}

燒錄步驟

  1. 用 USB 線連接 ESP32-S3 的燒錄接頭(不是 OTG 口)到電腦
  2. Arduino IDE 選好正確的 COM Port
  3. 確認上方 Arduino IDE 設定表的每個選項都設對
  4. 點擊上傳按鈕
  5. 上傳完成後,開啟 Serial Monitor,鮑率設 115200

正常啟動後,Serial Monitor 會看到:

=== ESP32-S3 網路列印機橋接器 ===
[WiFi] 連接到 MyWiFi ...
[WiFi] 已連線!IP: 192.168.1.137
[mDNS] 廣播名稱: ESP32-Printer.local
[TCP] 列印 Server 啟動,Port: 9100
-----------------------------------
  Windows: 標準TCP/IP → IP: 192.168.1.137  Port: 9100
  Mac:     IP → HP Jetdirect-Socket → 192.168.1.137
-----------------------------------
[USB] Host 已初始化
[USB] 等待裝置插入...

接著把印表機 USB 線透過 OTG 轉接頭插到 OTG 口,應該看到:

[USB] 偵測到新裝置,位址: 1
[USB] VID: 04F9  PID: 0027
[USB] 介面 Class: 0x07 SubClass: 0x01   ← 0x07 代表標準列印機
[USB] Bulk OUT: 0x01  MPS: 64
[USB] 列印機就緒!

看到「列印機就緒!」就代表硬體這端完全正常,可以繼續設定電腦端。


電腦端設定

Windows

  1. 開啟「控制台」→「裝置和印表機」→「新增印表機」
  1. 選「使用IP位址或主機名稱新增印表機」
  1. 裝置類型 ,類型選「TCP/IP 裝置」,主機名稱填 ESP32-S3 的 IP(Serial Monitor 顯示的那組)
  1. 出現「需要其他連接埠資訊」畫面時,選「自訂」→ 點「設定值」
  1. 確認通訊協定是 原始,連接埠號碼是 9100
  1. 確認通訊協定是 Raw,連接埠號碼是 9100
  2. 下一步後選擇印表機驅動程式,找你的印表機型號安裝。注意 !建議要先安裝好驅動程式,這時才能找得到哦!


列印測試

設定好之後,直接從 Word、記事本或任何程式選擇這台印表機印一頁測試。同時看 Serial Monitor,正常的輸出長這樣:

[TCP] 收到列印連線,來自: 192.168.1.50
[USB] 已送出 512 bytes
[USB] 已送出 512 bytes
[USB] 已送出 256 bytes
[TCP] 完成,共 1280 bytes,USB 傳輸: 成功

常見問題排除

插上印表機後沒有任何 USB 訊息

最常見的原因是 USB Mode 設定錯誤。重新確認 Tools → USB Mode → 選「USB-OTG (TinyUSB)」,重新燒錄。

介面 Class 顯示的不是 0x07

代表這台印表機不是標準 USB Printer Class,使用私有協定,ESP32 無法直接驅動。可以試試看是否有其他介面,但成功機率較低。

電腦新增印表機找不到

手動輸入 IP 和 Port 9100。不要依賴自動搜尋,ESP32 的 mDNS 廣播不一定被 Windows 的印表機精靈識別。

印出來是亂碼

驅動程式選錯了。回到「裝置和印表機」,重新選擇正確的印表機型號和驅動。ESP32 只是透明橋接,格式全靠驅動,驅動對了就能正常印。

USB 傳輸失敗

Serial Monitor 顯示 Transfer 錯誤傳送失敗,通常是 OTG 轉接頭接觸不良,或供電不足(印表機耗電較大時,建議 ESP32 用有足夠電流的充電頭供電,不要用電腦 USB 口)。


技術原理補充

USB Host 模式與雙核心分工

ESP32-S3 有兩個 CPU 核心,程式利用這個特性做了分工:

  • Core 0:跑 USB Host 任務,專心處理 USB 通訊事件
  • Core 1:跑 WiFi 和 TCP Server(Arduino 預設的核心)

兩件事分開跑,不會互相干擾,這也是為什麼 usbHostTaskxTaskCreatePinnedToCore 固定在 Core 0。

USB Bulk Transfer 的流量控制

程式每次最多送 512 bytes 給印表機,送完等 10ms。這個設計是為了讓印表機有時間消化資料,避免印表機內部 buffer 溢出導致資料遺失。如果你的印表機速度很快(高速雷射),可以把這個延遲調小或移除。

mDNS 廣播的作用

ESPmDNS 廣播 pdl-datastream 服務,這是 RAW 列印的標準 mDNS 服務類型。部分作業系統的列印服務會自動掃描這個服務,讓印表機自動出現在清單上,不需要手動輸入 IP。


結語

一片 ESP32-S3 加上一條 OTG 轉接線,幾百元的成本,讓束之高閣的舊印表機重新派上用場。如果你手邊剛好有支援標準 PCL 格式的舊印表機,這個方案的相容性會更好,電腦和 Android 手機都能輕鬆使用。

有問題歡迎在傑森創工 Facebook 粉絲團討論。