Emulador de Impressora Térmica via IP: testando ESC/POS sem papel

Testar impressão sem ter a impressora

Quem desenvolve PDV, delivery, comanda de cozinha ou emissor de cupom conhece o ciclo: muda o layout, manda imprimir, confere, rasga a bobina, repete. Com a impressora na mesa, isso já é lento. Sem ela, o teste simplesmente não acontece.

  • Home office sem impressora. A térmica fica na loja ou no escritório, e o código de impressão vira o único trecho que você não consegue testar de casa.
  • Bobina gasta em teste. Cada ajuste de alinhamento ou de quebra de linha custa papel e tempo.
  • Loja com várias impressoras. Caixa, cozinha e bar recebem cupons diferentes, e simular isso com uma impressora só, ou nenhuma, não dá.
  • Erro descoberto pelo cliente. Cupom cortado, item fora de ordem ou pedido saindo na impressora errada só aparecem na implantação.

A solução: uma impressora de mentira na sua rede

O printer.sh transforma o seu computador numa impressora térmica de rede de mentira. Ele cria um IP próprio na LAN, escuta a porta 9100 como uma impressora ESC/POS (Epson, Elgin, Bematech e similares) e mostra no terminal, em tempo real, o texto que iria para o papel.

Para a aplicação, a conexão é indistinguível da real: TCP puro na porta 9100, num IP que existe de verdade na rede, atendendo uma conexão por vez. O que muda é só a ponta final: em vez da cabeça térmica, os bytes chegam a um filtro que limpa os comandos e escreve o texto na tela.

Na prática, isso resolve cada ponto da lista acima:

  • Sem papel e sem hardware. Cada ajuste de layout é conferido na tela, sem gastar bobina.
  • Sem mudar o código. A aplicação continua enviando bytes para IP:9100. Só o endereço configurado aponta para o emulador, do mesmo jeito que seria configurado na loja.
  • Várias impressoras ao mesmo tempo. Caixa, cozinha e bar podem ter cada um o seu IP (192.168.5.111, .112, .113), cada um na sua janela.
  • Acessível de qualquer aparelho da rede. Como o IP existe na interface de rede, um tablet Android, outro PC ou um container Docker também conseguem imprimir nele.

Como uma impressora térmica de rede recebe dados

Uma impressora térmica de rede é, na prática, um socket TCP aberto na porta 9100 que entrega tudo o que recebe direto ao interpretador de comandos. Esse modo se chama RAW (ou JetDirect, nome herdado das placas de rede da HP). Não há protocolo por cima: nada de HTTP, IPP, LPD ou handshake de aplicação.

O ciclo de uma impressão é sempre o mesmo:

  1. A aplicação abre uma conexão TCP com IP:9100.
  2. Escreve um fluxo de bytes: texto misturado com comandos ESC/POS.
  3. Fecha a conexão. A impressora já vinha imprimindo enquanto os bytes chegavam.

ESC/POS é a linguagem criada pela Epson e adotada por quase todos os fabricantes de impressora de cupom. Cada comando começa com um byte de controle, principalmente ESC (0x1B) e GS (0x1D), seguido de uma letra e, às vezes, de parâmetros. Todo o resto é texto, enviado nos bytes da code page ativa.

Comandos comuns e como aparecem no emulador

O filtro do emulador descarta o byte ESC ou GS e o byte seguinte, depois remove os caracteres não imprimíveis. Parâmetros que caem na faixa imprimível do ASCII sobram como letras soltas. A coluna da direita é a saída real do printer-output.py para cada comando:

ComandoBytes (hex)FunçãoSaída no emulador
ESC @1B 40Inicializa a impressoraNada
ESC a n1B 61 nAlinhamento (0 esquerda, 1 centro, 2 direita)Nada com n = 00–02; sobra 0, 1 ou 2 com n = 30–32
ESC E n1B 45 nNegrito liga/desligaNada com n = 00/01
ESC ! n1B 21 nModo de impressão (tamanho, negrito)Sobra um caractere quando n é imprimível (30 vira 0)
GS ! n1D 21 nAltura e largura do caractereNada com valores comuns como 11
ESC d n1B 64 nAvança n linhasNada; as linhas em branco somem
LF0AImprime a linha e avançaQuebra de linha
GS V m1D 56 mCorte do papelNada com m = 00/01; sobra A ou B com m = 41/42
GS ( k …1D 28 6B …QR CodeO conteúdo do QR aparece, cercado de restos como k1A2k1Ck1P0
GS v 0 …1D 76 30 …Imagem raster (logo)Lixo binário, como 0��AB�801…
DLE EOT n10 04 nPede status em tempo realNada, e nenhuma resposta volta

Onde o emulador é idêntico ao hardware e onde não é

Tudo o que é rede e conexão é reproduzido; o que é renderização vira texto.

AspectoImpressora realEmulador (printer.sh)
EndereçoIP próprio na LANIP próprio na LAN (alias na interface)
TransporteTCP, porta 9100, RAWTCP, porta 9100, RAW
ConexõesEm geral uma por vezUma por vez com o nc do macOS; as demais esperam na fila
Comandos ESC/POSExecutadosDescartados; parâmetros imprimíveis sobram
Respostas de statusResponde a DLE EOT e GS aNão responde nada
VelocidadeLimitada pela mecânica; com o buffer cheio, o TCP segura o envioLê tudo na hora, sem limite
Code pageCP850, CP860, CP1252 etc., escolhida por ESC tSempre decodifica como UTF-8
SaídaPapel de 58 ou 80 mmTexto no terminal, sem largura fixa

Para validar conteúdo, ordem dos itens, quebras de linha e a conexão em si, o emulador basta. Para validar negrito, tamanho de fonte, logo e QR Code, ainda vale um teste final no papel.

Arquitetura do emulador

O emulador troca só a ponta final do caminho: a aplicação fala com o mesmo IP e a mesma porta, mas quem atende é um nc ligado a um IP extra do computador.

image-1024x733 Emulador de Impressora Térmica via IP: testando ESC/POS sem papel

Lido na horizontal, cada peça do emulador ocupa o lugar de uma parte da impressora: alias e nc fazem o papel da placa de rede, o filtro Python faz o papel do interpretador ESC/POS e a janela do Terminal substitui a bobina.

O ciclo de vida tem dois comandos:

  • ./printer.sh IP cria o alias (se ainda não existir), garante que o IP seja roteado localmente, abre a janela e sobe o pipeline nc | python3.
  • ./printer.sh stop IP mata o nc daquele IP e remove o alias e a rota local. As outras impressoras virtuais continuam rodando.

Código completo para macOS

O emulador são dois arquivos na mesma pasta: printer.sh cuida de rede, janela e listener; printer-output.py filtra os bytes e escreve o texto. O macOS já traz tudo o que eles usam: ifconfig, nc, osascript e, com as Command Line Tools, python3.

Instalação

mkdir -p ~/printer-emulador && cd ~/printer-emulador
# salve printer.sh e printer-output.py nesta pasta
chmod +x printer.sh
xcode-select --install   # só se o python3 ainda não existir
./printer.sh 192.168.5.111

printer.sh

#!/bin/bash

set -e

# Interface da rota padrão (sobrescreva com INTERFACE=en1 ./printer.sh ...)
INTERFACE="${INTERFACE:-$(route -n get default 2>/dev/null | awk '/interface:/ { print $2; exit }')}"
PORT=9100
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"

if [ -z "$1" ]; then
    echo "Uso:"
    echo "  $0 <IP>"
    echo "  $0 stop <IP>"
    echo
    echo "Exemplos:"
    echo "  $0 192.168.5.111"
    echo "  $0 192.168.5.112"
    echo "  $0 stop 192.168.5.111"
    echo "  INTERFACE=en0 $0 stop 192.168.5.111   # remove um alias criado na interface antiga"
    exit 1
fi

if [ -z "$INTERFACE" ]; then
    echo "Interface de rede não encontrada. Use: INTERFACE=en1 $0 $*"
    exit 1
fi

if ! ifconfig "$INTERFACE" >/dev/null 2>&1; then
    echo "Interface de rede inválida: $INTERFACE"
    exit 1
fi

valid_ip() {
    local octet
    local -a octets
    [[ "$1" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] || return 1
    IFS=. read -r -a octets <<< "$1"
    for octet in "${octets[@]}"; do
        (( 10#$octet <= 255 )) || return 1
    done
}

# -------------------------
# STOP
# -------------------------

if [ "$1" = "stop" ]; then

    IP="$2"

    if ! valid_ip "$IP"; then
        echo "Informe o IP:"
        echo "$0 stop 192.168.5.111"
        exit 1
    fi

    echo "🛑 Parando impressora virtual $IP:$PORT"

    # Mata somente o nc desse IP
    pkill -f "nc -lk ${IP} ${PORT}" 2>/dev/null || true

    # Remove alias
    sudo ifconfig "$INTERFACE" -alias "$IP" 2>/dev/null || true

    if route -n get "$IP" 2>/dev/null | grep -qE 'interface: lo0$'; then
        sudo route -n delete -host "$IP"
    fi

    echo "✓ Impressora $IP removida"

    exit 0
fi


# -------------------------
# START
# -------------------------

IP="$1"

if ! valid_ip "$IP"; then
    echo "IP inválido: $IP"
    exit 1
fi

echo
echo "🖨️  ESC/POS Virtual Printer"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "IP:    $IP"
echo "Porta: $PORT"
echo "Interface: $INTERFACE"
echo

if ifconfig "$INTERFACE" | grep -q 'status: inactive'; then
    echo "Interface $INTERFACE inativa. Conecte-a à rede ou escolha outra com INTERFACE."
    exit 1
fi


# Verifica alias
if ! ifconfig "$INTERFACE" | grep -q "inet $IP "; then

    echo "Adicionando IP $IP..."

    sudo ifconfig "$INTERFACE" alias "$IP" netmask 255.255.255.0

else

    echo "✓ IP já configurado"

fi


# Um alias pode manter uma entrada ARP antiga em vez de uma rota local.
if ! ROUTE_INFO="$(route -n get "$IP" 2>&1)"; then
    echo "Não foi possível consultar a rota de $IP: $ROUTE_INFO"
    exit 1
fi

if ! printf '%s\n' "$ROUTE_INFO" | grep -qE 'interface: lo0$'; then
    if printf '%s\n' "$ROUTE_INFO" | grep -qE "destination: ${IP//./\\.}$"; then
        sudo route -n delete -host "$IP"
    fi
    sudo route -n add -host "$IP" -interface lo0
fi

if ! route -n get "$IP" | grep -qE 'interface: lo0$'; then
    echo "A rota de $IP não aponta para lo0. Impressora não iniciada."
    exit 1
fi

# Abre terminal pequeno
osascript - "$IP" "$PORT" "$SCRIPT_DIR" <<'EOF'
on run argv
    set printerIP to item 1 of argv
    set printerPort to item 2 of argv
    set scriptDirectory to item 3 of argv
    set quotedIP to quoted form of printerIP
    set quotedPort to quoted form of printerPort
    set displayScript to quoted form of (scriptDirectory & "/printer-output.py")
    set commandText to "clear; printf '\\033[1;32m🖨 ESC/POS VIRTUAL PRINTER\\033[0m\\n'; printf '\\033[1mIP: %s  |  Porta: %s\\033[0m\\n' " & quotedIP & " " & quotedPort & "; echo '────────────────────────────────────────'; echo ''; nc -lk " & quotedIP & " " & quotedPort & " | python3 -u " & displayScript

    tell application "Terminal"
        activate
        set w to do script commandText
        delay 0.5
    end tell
end run
EOF


echo
echo "✓ Impressora virtual iniciada"
echo
echo "Destino:"
echo "tcp://$IP:$PORT"
echo

printer-output.py

#!/usr/bin/env python3

import codecs
import sys


decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
skip_command_byte = False
sys.stdout.write("\033[1;97m")
sys.stdout.flush()

try:
    while True:
        chunk = sys.stdin.buffer.read1(4096)
        if not chunk:
            break

        text_bytes = bytearray()
        for byte in chunk:
            if skip_command_byte:
                skip_command_byte = False
            elif byte in (0x1B, 0x1D):
                skip_command_byte = True
            else:
                text_bytes.append(byte)

        text = decoder.decode(text_bytes)
        readable = "".join(
            char for char in text if char in "\r\n\t" or char.isprintable()
        )
        if readable:
            sys.stdout.write(readable)
            sys.stdout.flush()
finally:
    sys.stdout.write("\033[0m")
    sys.stdout.flush()

O que cada parte faz

  1. Configuração (linhas 3–8). set -e aborta no primeiro erro. INTERFACE vem da rota padrão: route -n get default diz qual placa leva à rede, e o awk pega o nome depois de interface:. Se a variável já vier preenchida (INTERFACE=en1 ./printer.sh ...), ela prevalece. PORT é a porta RAW padrão e SCRIPT_DIR guarda o caminho absoluto da pasta, para achar o printer-output.py de qualquer diretório.
  2. Ajuda (linhas 10–21). Sem argumentos, mostra o uso e sai com código 1. O último exemplo lembra que o stop precisa da interface onde o alias foi criado: se você trocou do Wi-Fi para o cabo, passe a antiga em INTERFACE.
  3. Validações (linhas 23–41). O script para se não achou interface ou se o ifconfig não a reconhece. valid_ip confere o formato n.n.n.n e se cada octeto vai até 255; o 10# força base 10, para que 08 e 09 não sejam lidos como octal. A função vale tanto no stop quanto no início.
  4. Parada (linhas 47–72). pkill -f compara a linha de comando inteira dos processos, então mata só o nc daquele IP e deixa as outras impressoras de pé. Em seguida, ifconfig "$INTERFACE" -alias remove o endereço, e a rota de host em lo0, se ainda existir, é apagada. O pkill e o ifconfig terminam em || true para o stop não falhar se a impressora já estiver parada.
  5. Interface ativa (linhas 94–97). Se o ifconfig mostra status: inactive (cabo solto, Wi-Fi desligado), o script para antes de mexer na rede.
  6. Alias de IP (linhas 100–111). O grep -q "inet $IP " verifica se o IP já existe; o espaço no fim evita confundir .11 com .111. Se não existir, ifconfig "$INTERFACE" alias adiciona um segundo endereço na mesma placa. A partir daí o Mac responde ARP por esse IP, e qualquer aparelho da rede chega nele.
  7. Rota local (linhas 114–130). O macOS pode guardar uma entrada ARP antiga para o IP, de quando ele era de outro aparelho ou de um teste anterior. Com ela, conexões feitas do próprio Mac saem pela placa em vez de chegar ao nc. O script consulta route -n get IP: se a rota não usa lo0, apaga a entrada de host antiga e cria route add -host IP -interface lo0. Se ainda assim não apontar para lo0, para sem abrir a janela.
  8. Janela do Terminal (linhas 133–149). O osascript roda um AppleScript embutido via heredoc. IP, porta e pasta entram como argv, e quoted form of coloca cada um entre aspas simples, o que protege contra espaços no caminho. O do script abre uma janela nova, limpa a tela, imprime o cabeçalho com cores ANSI e sobe o pipeline.
  9. O pipeline. nc -l escuta e -k o faz voltar a escutar quando o cliente fecha a conexão, em vez de sair. Com o IP antes da porta, o nc se liga só ao alias, e não a todas as interfaces. python3 -u desliga o buffer de saída do Python.
  10. read1(4096). Diferente de read(4096), devolve o que já chegou sem esperar completar 4096 bytes. É isso que deixa a saída em tempo real.
  11. Máquina de estados. skip_command_byte marca que o próximo byte é a letra de um comando ESC/GS e deve ser descartado. Como a variável vive fora do laço, funciona mesmo quando o ESC chega no fim de um pacote TCP e a letra no começo do próximo.
  12. Decodificador incremental. Um ç em UTF-8 ocupa dois bytes (C3 A7) e pode chegar dividido entre dois pacotes. O getincrementaldecoder guarda o byte incompleto até o próximo pedaço, e errors="replace" troca bytes inválidos por � em vez de derrubar o script.
  13. Filtro final e cores. Ficam só os caracteres imprimíveis, mais \r, \n e \t. O texto sai em branco brilhante e negrito (\033[1;97m), e o finally restaura a cor do terminal quando o nc fecha o pipe.

Permissões que o macOS pode pedir

  • sudo: senha de administrador para criar e remover o alias.
  • Automação: na primeira execução, o macOS pergunta se o app de onde você roda o script pode controlar o Terminal. Aceite.
  • Firewall: o nc é software do sistema e, por padrão, pode receber conexões. Se o firewall estiver bloqueando todas as conexões de entrada, outros aparelhos da rede não vão conseguir imprimir.

Ajustes úteis

  • Interface certa. O script usa a interface da rota padrão. en0 costuma ser o Wi-Fi nos MacBooks e a Ethernet no Mac mini e no iMac. Para escolher outra, rode INTERFACE=en1 ./printer.sh 192.168.5.111, e passe a mesma variável no stop.
  • Máscara. Se o ifconfig recusar o alias por conflito de rota, troque para netmask 255.255.255.255: o alias fica só com o próprio endereço e não disputa a rota da sub-rede.

Código para Linux e o que muda

O printer-output.py é o mesmo nos dois sistemas; só o script de controle precisa de uma versão própria, o printer-linux.sh. Três peças do macOS não existem no Linux: o ifconfig alias (vira ip addr add), o osascript com o app Terminal (vira o emulador de terminal instalado) e a garantia de um nc com -k.

AspectomacOS (printer.sh)Linux (printer-linux.sh)
Criar o IP extraifconfig en0 alias IP netmask 255.255.255.0ip addr add IP/24 dev eth0
Remover o IPifconfig en0 -alias IPip addr del IP/24 dev eth0
Garantir a rota localroute -n add -host IP -interface lo0ip route replace local IP/32 ... table local
InterfaceDetectada pela rota padrão, ou INTERFACE=...Detectada pela rota padrão, ou INTERFACE=...
Abrir a janelaosascript + Terminalgnome-terminal, konsole, xfce4-terminal ou xterm
Sem interface gráficaNão se aplicaRoda no terminal atual (SSH, servidor) ou com --here
netcatnc BSD nativo, uma conexão por veznetcat-openbsd (uma por vez) ou ncat (aceita conexões simultâneas)
FirewallFirewall de aplicativos do macOSufw ou firewalld, liberando 9100/tcp
Ao pararA janela continua abertaA janela fecha junto com o listener

Pré-requisitos e uso

# Debian / Ubuntu
sudo apt install netcat-openbsd python3 iproute2

# Fedora / RHEL
sudo dnf install nmap-ncat python3 iproute

chmod +x printer-linux.sh
./printer-linux.sh 192.168.5.111          # abre uma janela nova
./printer-linux.sh 192.168.5.111 --here   # roda no terminal atual
./printer-linux.sh stop 192.168.5.111

printer-linux.sh

#!/usr/bin/env bash

set -e

PORT=9100
PREFIX=24
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"

# Interface da rota padrão (sobrescreva com INTERFACE=eth0 ./printer-linux.sh ...)
INTERFACE="${INTERFACE:-$(ip route show default 2>/dev/null | awk '{for (i = 1; i < NF; i++) if ($i == "dev") { print $(i + 1); exit }}')}"

if [ -z "$1" ]; then
    echo "Uso:"
    echo "  $0 <IP> [--here]"
    echo "  $0 stop <IP>"
    echo
    echo "Exemplos:"
    echo "  $0 192.168.5.111"
    echo "  $0 192.168.5.112 --here   # roda no terminal atual"
    echo "  $0 stop 192.168.5.111"
    echo "  INTERFACE=eth0 $0 stop 192.168.5.111   # remove um IP criado na interface antiga"
    exit 1
fi

if [ -z "$INTERFACE" ]; then
    echo "Interface de rede não encontrada. Use: INTERFACE=eth0 $0 $*"
    exit 1
fi

if ! LINK_INFO="$(ip -o link show dev "$INTERFACE" 2>&1)"; then
    echo "Não foi possível consultar a interface $INTERFACE: $LINK_INFO"
    exit 1
fi

valid_ip() {
    local octet
    local -a octets
    [[ "$1" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] || return 1
    IFS=. read -r -a octets <<< "$1"
    for octet in "${octets[@]}"; do
        (( 10#$octet <= 255 )) || return 1
    done
}

# -------------------------
# STOP
# -------------------------

if [ "$1" = "stop" ]; then

    IP="$2"

    if ! valid_ip "$IP"; then
        echo "Informe o IP:"
        echo "$0 stop 192.168.5.111"
        exit 1
    fi

    echo "🛑 Parando impressora virtual $IP:$PORT"

    # Mata somente o listener desse IP (nc ou ncat)
    pkill -f "nc(at)? -lk ${IP} ${PORT}" 2>/dev/null || true

    # Remove o IP extra
    sudo ip addr del "${IP}/${PREFIX}" dev "$INTERFACE" 2>/dev/null || true

    echo "✓ Impressora $IP removida"

    exit 0
fi


# -------------------------
# START
# -------------------------

IP="$1"
MODE="$2"

if ! valid_ip "$IP"; then
    echo "IP inválido: $IP"
    exit 1
fi

LINK_FLAGS="${LINK_INFO#*<}"
LINK_FLAGS="${LINK_FLAGS%%>*}"
if [[ ",$LINK_FLAGS," != *,UP,* || ",$LINK_FLAGS," != *,LOWER_UP,* ]]; then
    echo "Interface $INTERFACE inativa. Conecte-a à rede ou escolha outra com INTERFACE."
    exit 1
fi

# Precisa de um netcat com -k (OpenBSD ou ncat); o netcat-traditional não serve
if command -v nc >/dev/null 2>&1 && nc -h 2>&1 | grep -qiE 'keep inbound|keep-open'; then
    LISTENER="nc"
elif command -v ncat >/dev/null 2>&1; then
    LISTENER="ncat"
else
    echo "Nenhum netcat com suporte a -k encontrado."
    echo "  Debian/Ubuntu: sudo apt install netcat-openbsd"
    echo "  Fedora/RHEL:   sudo dnf install nmap-ncat"
    exit 1
fi

echo
echo "🖨️  ESC/POS Virtual Printer"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "IP:        $IP"
echo "Porta:     $PORT"
echo "Interface: $INTERFACE"
echo


# Verifica o IP extra
if ! ip -4 addr show dev "$INTERFACE" | grep -qF "inet ${IP}/"; then

    echo "Adicionando IP $IP em $INTERFACE..."

    sudo ip addr add "${IP}/${PREFIX}" dev "$INTERFACE"

else

    echo "✓ IP já configurado"

fi


if ! LOCAL_ROUTE="$(ip -4 route show table local type local exact "${IP}/32" 2>&1)"; then
    echo "Não foi possível consultar a rota local de $IP: $LOCAL_ROUTE"
    exit 1
fi

# O kernel normalmente cria essa rota ao adicionar o endereço.
if [ -z "$LOCAL_ROUTE" ]; then
    echo "Restaurando rota local de $IP..."
    sudo ip -4 route replace local "${IP}/32" dev "$INTERFACE" proto kernel scope host src "$IP" table local
fi

if ! ROUTE_INFO="$(ip -4 route get "$IP" 2>&1)"; then
    echo "Não foi possível consultar a rota de $IP: $ROUTE_INFO"
    exit 1
fi

if ! printf '%s\n' "$ROUTE_INFO" | grep -qE "^local ${IP//./\\.}[[:space:]]"; then
    echo "O IP $IP não está sendo roteado localmente. Impressora não iniciada."
    echo "$ROUTE_INFO"
    echo "Verifique as regras de roteamento com: ip -4 rule show"
    exit 1
fi

DISPLAY_SCRIPT="$(printf %q "$SCRIPT_DIR/printer-output.py")"
CMD="clear; printf '\033[1;32m🖨 ESC/POS VIRTUAL PRINTER\033[0m\n'; printf '\033[1mIP: %s  |  Porta: %s\033[0m\n' $IP $PORT; echo '────────────────────────────────────────'; echo; $LISTENER -lk $IP $PORT | python3 -u $DISPLAY_SCRIPT; echo; read -rp 'Listener encerrado. Enter para fechar. '"

# Abre uma janela nova no primeiro terminal gráfico encontrado
open_window() {
    [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ] || return 1

    if command -v gnome-terminal >/dev/null 2>&1; then
        gnome-terminal -- bash -c "$CMD"
    elif command -v konsole >/dev/null 2>&1; then
        nohup konsole -e bash -c "$CMD" >/dev/null 2>&1 &
    elif command -v xfce4-terminal >/dev/null 2>&1; then
        nohup xfce4-terminal --title="Impressora $IP" -x bash -c "$CMD" >/dev/null 2>&1 &
    elif command -v xterm >/dev/null 2>&1; then
        nohup xterm -T "Impressora $IP" -e bash -c "$CMD" >/dev/null 2>&1 &
    else
        return 1
    fi
}

echo
echo "Destino:"
echo "tcp://$IP:$PORT"
echo

if [ "$MODE" != "--here" ] && open_window; then
    echo "✓ Impressora virtual iniciada em nova janela"
    echo
else
    echo "✓ Impressora virtual rodando neste terminal (Ctrl+C para parar)"
    exec bash -c "$CMD"
fi

O que muda em relação ao macOS

  1. Interface automática. ip route show default devolve algo como default via 192.168.5.1 dev eth0 ..., e o awk pega a palavra depois de dev. Em seguida, ip -o link show confirma que a placa existe. Para forçar outra: INTERFACE=wlp2s0 ./printer-linux.sh 192.168.5.111.
  2. Validação do IP. O IP vai para o sudo e para dentro de um bash -c, então o script recusa qualquer coisa fora do formato n.n.n.n ou com octeto acima de 255. A função valid_ip é a mesma do macOS.
  3. Interface ativa. A saída de ip -o link traz as flags entre < e >, como <BROADCAST,MULTICAST,UP,LOWER_UP>. O script exige UP (placa ligada) e LOWER_UP (cabo ou Wi-Fi conectado); sem as duas, para antes de mexer na rede.
  4. Detecção do netcat. O Linux tem três netcats diferentes. O script procura no nc -h a descrição do -k do OpenBSD (Keep inbound) ou do ncat (keep-open). O netcat-traditional, que não tem -k, cai na mensagem de instalação.
  5. IP extra com ip addr. ip addr add IP/24 cria um endereço secundário na placa. Ele some no reboot e pode sumir quando o NetworkManager reconecta; nesse caso, basta rodar o script de novo.
  6. Rota local. Ao receber o endereço, o kernel cria na tabela local a rota que entrega esse IP à própria máquina. Se ela não estiver lá, o script a recria com ip route replace local IP/32 ... table local. Depois, ip route get IP precisa começar com local; se uma regra de roteamento (VPN, policy routing) desviar o IP, o script mostra a rota e para, sugerindo ip -4 rule show.
  7. Janela. Sem DISPLAY nem WAYLAND_DISPLAY, não há interface gráfica e o listener roda no próprio terminal. Com interface gráfica, o script usa o primeiro terminal que encontrar, e o nohup … & desacopla a janela do script.
  8. Parada. O padrão nc(at)? -lk IP PORT do pkill pega tanto nc quanto ncat. A linha de comando do bash -c da janela também contém esse texto, então ele é encerrado junto e a janela fecha. Se o listener cair sozinho (IP já em uso, por exemplo), o read no fim mantém a janela aberta para mostrar o erro.

Firewall e WSL

sudo ufw allow 9100/tcp                 # Ubuntu com ufw
sudo firewall-cmd --add-port=9100/tcp   # Fedora/RHEL, vale até o reboot

No WSL2, a rede passa por NAT: o IP extra fica dentro da VM e não aparece na LAN. Ali, o emulador serve para aplicações rodando dentro do próprio WSL.

Integrando com a sua aplicação

O código de impressão não muda: basta trocar o IP da impressora pelo IP do emulador. Os exemplos abaixo usam as bibliotecas ESC/POS mais comuns, todas enviando para 192.168.5.111:9100.

Python, com python-escpos

from escpos.printer import Network

p = Network("192.168.5.111", port=9100)
p.set(align="center", bold=True)
p.text("PEDIDO #1234\n")
p.set(align="left", bold=False)
p.text("1x X-Burguer      R$ 25,90\n")
p.cut()
p.close()

Node.js, com node-thermal-printer

const { ThermalPrinter, PrinterTypes } = require("node-thermal-printer");

async function imprimir() {
  const printer = new ThermalPrinter({
    type: PrinterTypes.EPSON,
    interface: "tcp://192.168.5.111:9100",
  });

  printer.alignCenter();
  printer.println("PEDIDO #1234");
  printer.alignLeft();
  printer.println("1x X-Burguer      R$ 25,90");
  printer.cut();
  await printer.execute();
}

imprimir();

PHP, com escpos-php

use Mike42\Escpos\PrintConnectors\NetworkPrintConnector;
use Mike42\Escpos\Printer;

$connector = new NetworkPrintConnector("192.168.5.111", 9100);
$printer = new Printer($connector);
$printer->setJustification(Printer::JUSTIFY_CENTER);
$printer->text("PEDIDO #1234\n");
$printer->cut();
$printer->close();

C#, com socket puro

using System.Net.Sockets;
using System.Text;

using var client = new TcpClient("192.168.5.111", 9100);
using var stream = client.GetStream();

byte[] init = { 0x1B, 0x40 };        // ESC @
byte[] cut = { 0x1D, 0x56, 0x00 };   // GS V 0
byte[] text = Encoding.UTF8.GetBytes("PEDIDO #1234\nAção: entregar\n\n");

stream.Write(init);
stream.Write(text);
stream.Write(cut);

Bibliotecas como python-escpos convertem o texto para a code page da impressora antes de enviar. Nesse caso, os acentos chegam fora de UTF-8 e aparecem como � no emulador; a seção seguinte mostra como ajustar.

Outros aparelhos da rede

O IP extra está na placa de rede, então qualquer aparelho da mesma sub-rede chega nele: tablet de comanda, outro PC, maquininha com app de PDV. Configure nele o IP do emulador e a porta 9100. Para conferir a partir de outro computador:

printf 'teste de outro PC\n' | nc -w 1 192.168.5.111 9100

Docker

Containers em geral alcançam o IP extra sem configuração, porque ele é um endereço local do host. Para confirmar no seu ambiente:

docker run --rm python:3-alpine python -c "import socket; s = socket.create_connection(('192.168.5.111', 9100)); s.sendall(b'teste via docker\n'); s.close()"

Várias impressoras

Cada chamada cria um IP e abre uma janela própria, então dá para simular o ambiente inteiro de uma loja:

./printer.sh 192.168.5.111   # caixa
./printer.sh 192.168.5.112   # cozinha
./printer.sh 192.168.5.113   # bar

Só na própria máquina, sem sudo

Se a aplicação roda no mesmo computador e não precisa de um IP da LAN, dá para pular o alias e escutar no loopback. Portas acima de 1024 não exigem root:

nc -lk 127.0.0.1 9100 | python3 -u printer-output.py

Limitações e solução de problemas

O emulador valida conexão e conteúdo, mas quatro diferenças em relação ao hardware afetam o que você vê na tela:

  • Code page. O filtro sempre decodifica UTF-8. Se a aplicação envia CP850 ou CP860, comuns em impressoras vendidas no Brasil, ção vira ��o. A correção está logo abaixo.
  • Parâmetros que sobram. Só o byte logo após ESC ou GS é descartado. Letras soltas como B, 0 ou k1P0 no meio do texto são parâmetros de comandos (veja a tabela de comandos acima), não erro da aplicação.
  • Sem respostas de status. Aplicações que consultam o status antes de imprimir (DLE EOT, GS a) esperam uma resposta que nunca vem e podem travar até o timeout. No ambiente de teste, desligue essa verificação.
  • Sem limite de velocidade. O emulador lê tudo na hora. Uma impressora real é mais lenta e, com o buffer cheio, segura o envio pelo TCP; timeouts de escrita calibrados no emulador podem estourar no hardware.

Para aplicações que enviam CP850, troque a linha do decodificador no printer-output.py (use cp860 se for o caso):

decoder = codecs.getincrementaldecoder("cp850")(errors="replace")

Problemas comuns

SintomaCausa provávelSolução
nc sai na hora com Address already in useOutro processo já escuta na 9100 desse IPlsof -nP -iTCP:9100 -sTCP:LISTEN, depois ./printer.sh stop IP
127.0.0.1:9100 ocupada numa máquina com FlutterO Dart DevTools usa a porta 9100 no loopbackUsar o alias de IP, que não conflita, ou outra porta
nc reclama de assign requested addressO alias não existe na interfaceRodar o script de novo e conferir a interface
Outro aparelho não conectaIP fora da sub-rede, interface errada ou firewallConferir máscara e INTERFACE; liberar 9100/tcp
A impressão sai numa impressora de verdadeO IP já tinha dono na redeAntes de escolher, confirmar que ping -c 1 IP não responde
Acentos viram �Aplicação envia CP850 ou CP860Trocar o decodificador, como acima
O IP sumiuReboot, troca de Wi-Fi ou NetworkManagerRodar o script de novo
Interface … inativaA interface da rota padrão está sem cabo ou sem Wi-FiConectar a placa ou escolher outra com INTERFACE=
A rota não aponta para lo0 (macOS) ou o IP não está sendo roteado localmente (Linux)Entrada de rota antiga, VPN ou regra de roteamento desviando o IPNo macOS, conferir route -n get IP; no Linux, ip -4 rule show; ou escolher outro IP

Boas práticas

  • Use IPs fora da faixa do DHCP do roteador, para não colidir com aparelhos que entram na rede depois.
  • Nunca use o IP da impressora real enquanto ela estiver ligada na mesma rede: os dois responderiam ARP pelo mesmo endereço.
  • Mantenha um IP por impressora lógica (caixa, cozinha, bar), igual à configuração da loja.
  • Ao terminar, rode stop para remover o alias e liberar o IP.

Testando a impressora

Com o emulador rodando em 192.168.5.111, este comando envia um cupom de teste com inicialização, centralização, negrito, acentos e corte de papel, como uma aplicação real faria. Funciona igual no macOS e no Linux:

printf '\033@\033a\001*** TESTE DE IMPRESSAO ***\n\033a\000\033E\001Pedido #1234\033E\000\n1x X-Burguer ........ R$ 25,90\n1x Refrigerante ...... R$  6,00\n--------------------------------\nTOTAL ................ R$ 31,90\nAcentos: ação, pão, café\n\n\n\035V\000' | nc -w 1 192.168.5.111 9100

Na janela do emulador, os comandos somem e fica só o texto do cupom:

*** TESTE DE IMPRESSAO ***
Pedido #1234
1x X-Burguer ........ R$ 25,90
1x Refrigerante ...... R$  6,00
--------------------------------
TOTAL ................ R$ 31,90
Acentos: ação, pão, café

No comando, \033@ inicializa a impressora, \033a\001 centraliza, \033E\001 liga o negrito e \035V\000 corta o papel. O -w 1 faz o nc encerrar a conexão depois de enviar, como uma aplicação faz ao terminar a impressão.

Duas variações:

  • Com ncat (padrão no Fedora e RHEL): troque nc -w 1 por ncat --send-only, que encerra quando a entrada acaba.
  • Sem netcat, só com bash (o /dev/tcp não existe no zsh, por isso o bash -c):
bash -c "printf 'Teste via /dev/tcp\n' > /dev/tcp/192.168.5.111/9100"