Retrofitting a V90 modem with an ESP8266
Introduction
References
These projects have all supported the work I did to make my version of a WIFI modem. While it is relatively trivial to do the basic things like getting working AT commands, there is a lot involved in protocols like ZModem.
Zmodem Protocol
ZMODEM is a streaming file transfer protocol designed by Chuck Forsberg at Omen Technology. It maximises throughput by continuously transmitting data unless the receiver interrupts to request retransmission, effectively using the entire file as a sliding window. The protocol is unidirectional — the forward channel carries file data while the reverse channel carries only control information.
Framing
Every ZMODEM transaction is composed of frames. A frame consists of a header followed by zero or more data subpackets. Headers carry a frame type byte and four bytes of flags or a 32-bit file position. Data subpackets carry the file payload.
Header Formats
There are three header encodings. The receiver must always use HEX headers. The sender may use any encoding.
HEX header — All values are transmitted as printable lowercase hex digits using the character set 0123456789abcdef. The header is bookended with a CR/LF and, for most frame types, an XON character. XON is omitted after ZACK (to protect flow control during streaming) and ZFIN (to allow clean session teardown).
ZPAD ZPAD ZDLE ZHEX type[2] flags[8] crc16[4] CR LF [XON] 2A 2A 18 42 (hex) (hex) (hex)BIN16 header — Binary with 16-bit CRC. All bytes after the preamble are ZDLE-escaped.
ZPAD ZDLE ZBIN type f3/p0 f2/p1 f1/p2 f0/p3 crc16[2] 2A 18 41 .. .. .. .. .. .. ..BIN32 header — Binary with 32-bit CRC. Identical layout but with a four-byte CRC.
ZPAD ZDLE ZBIN32 type f3/p0 f2/p1 f1/p2 f0/p3 crc32[4] 2A 18 43 .. .. .. .. .. .. .. .. ..Note the byte ordering: when carrying flags the bytes are indexed F3 F2 F1 F0 (MSB first). When carrying a numeric file position the bytes are P0 P1 P2 P3 (LSB first).
ZDLE Escape Encoding
ZMODEM achieves data transparency by escaping special byte values with the ZDLE character (0x18, Ctrl-X). When the receiver sees ZDLE followed by a byte with bit 6 set and bit 5 clear, it XORs that byte with 0x40 to recover the original value.
The following bytes are always escaped: 0x10 (DLE), 0x11 (XON), 0x13 (XOFF), 0x18 (ZDLE/CAN), 0x90, 0x91, 0x93. If preceded by 0x40 or 0xC0 (@), bytes 0x0D and 0x8D are also escaped to protect against the Telenet CR-@-CR escape sequence.
Five consecutive CAN (0x18) characters abort a session. Implementations send eight CAN characters followed by ten backspaces as insurance.
Data Subpackets
Data subpackets immediately follow their associated binary header. Each subpacket contains 0–1024 bytes of ZDLE-escaped data (some implementations support up to 8192 bytes), terminated by a ZDLE-escaped subpacket type byte, followed by a ZDLE-escaped CRC covering the data and the type byte.
| Type | Value | End of frame | Response expected |
|---|---|---|---|
| ZCRCG | 0x69 | No | None (errors only) |
| ZCRCQ | 0x6A | No | ZACK expected |
| ZCRCE | 0x68 | Yes | None (errors only) |
| ZCRCW | 0x6B | Yes | ZACK expected before next |
Recommended subpacket sizes: 256 bytes below 2400 bps, 512 bytes at 2400 bps, 1024 bytes above 4800 bps or on clean links. The sender may dynamically adjust the subpacket size in response to errors.
Frame Types
| Frame | Value | Direction | Header data |
|---|---|---|---|
| ZRQINIT | 0x00 | S → R | Capabilities (usually 0) |
| ZRINIT | 0x01 | R → S | Receiver capability flags |
| ZSINIT | 0x02 | S → R | Sender flags + Attn string |
| ZACK | 0x03 | Both | File offset |
| ZFILE | 0x04 | S → R | File options + metadata |
| ZSKIP | 0x05 | R → S | — |
| ZNAK | 0x06 | Both | — |
| ZABORT | 0x07 | R → S | — |
| ZFIN | 0x08 | Both | — |
| ZRPOS | 0x09 | R → S | Resume file offset |
| ZDATA | 0x0A | S → R | File offset |
| ZEOF | 0x0B | S → R | Final file offset |
| ZFERR | 0x0C | R → S | — |
| ZCRC | 0x0D | Both | CRC-32 of file |
| ZFREECNT | 0x11 | S → R | Free disk space |
ZRINIT Capability Flags (ZF0)
The receiver declares its capabilities in the ZRINIT header. ZP0 and ZP1 carry the receiver’s buffer size in bytes, or 0 if nonstop streaming is allowed.
| Flag | Value | Meaning |
|---|---|---|
| CANFDX | 0x01 | Can send and receive full duplex |
| CANOVIO | 0x02 | Can receive data during disk I/O |
| CANBRK | 0x04 | Can send a break signal |
| CANCRY | 0x08 | Can decrypt |
| CANLZW | 0x10 | Can LZ decompress |
| CANFC32 | 0x20 | Can use 32-bit frame check (CRC-32) |
| ESCCTL | 0x40 | Expects control characters escaped |
| ESC8 | 0x80 | Expects 8th-bit characters escaped |
File Transfer: Complete Packet Exchange
A single-file transfer with no errors proceeds as follows. The → arrow indicates data flowing from sender to receiver, and ← indicates the reverse channel.
sequenceDiagram participant S as Sender participant R as Receiver
Note over S,R: Session Startup S->>R: ZRQINIT (HEX, P0–P3 = 0) R->>S: ZRINIT (HEX, capability flags + buffer size)
Note over S,R: File Offer S->>R: ZFILE (BIN, ZF0=conv ZF1=mgmt ZF2=xport) S->>R: ZCRCW subpacket: filename\0 length date mode serial\0
Note over S,R: File Negotiation R->>S: ZRPOS (HEX, offset = 0)
Note over S,R: Data Streaming S->>R: ZDATA (BIN32, offset = 0) S->>R: ZCRCG subpacket (1024 bytes + CRC) S->>R: ZCRCG subpacket (1024 bytes + CRC) S->>R: ZCRCG subpacket (1024 bytes + CRC) S->>R: ZCRCE subpacket (final bytes + CRC)
Note over S,R: End of File S->>R: ZEOF (BIN, offset = file length) R->>S: ZRINIT (HEX, ready for next file)
Note over S,R: Session Teardown S->>R: ZFIN (HEX) R->>S: ZFIN (HEX) S->>R: "OO" (raw ASCII, Over and Out)Step-by-Step Breakdown
The examples below trace a complete transfer of a 1200-byte file named hello.txt (modified at Unix timestamp 1740000000, octal 14744107400). The receiver advertises CANFDX, CANOVIO, and CANFC32 (0x23) with a buffer size of 0 (nonstop streaming). CRC values in the hex dumps are shown as cc cc (16-bit) or cc cc cc cc (32-bit) — they are computed over the type byte and the four data bytes using CRC-16/CCITT (0x1021, init 0x0000) for HEX and BIN16 frames, or CRC-32 for BIN32 frames.
1. ZRQINIT — Sender requests initialisation
The sender transmits a HEX ZRQINIT header with type 0x00 and all four data bytes zero. The rz\r\n prefix auto-starts the receiver.
Wire bytes (hex):72 7A 0D 0A "rz" CR LF — auto-start prefix2A 2A 18 42 ZPAD ZPAD ZDLE ZHEX — HEX header preamble30 30 type 0x00 (ZRQINIT) as two hex ASCII digits30 30 30 30 30 30 30 30 P0–P3 = 0x00000000 as eight hex digits30 30 30 30 CRC-16 as four hex digits0D 0A CR LF11 XON
Readable: rz\r\n**\x18B00000000000000\r\n\x11In a HEX header every value after the B encoding marker is represented as two lowercase hex ASCII characters per byte, making the frame fully printable and safe on control-character-hostile channels.
2. ZRINIT — Receiver declares capabilities
The receiver responds with a HEX ZRINIT header. The four data bytes carry both buffer size and capability flags in a shared layout:
| Byte position | Flag name | Numeric name | Value | Meaning |
|---|---|---|---|---|
| byte 0 | F3 | P0 | 0x00 | Buffer size low byte (0 = nonstop) |
| byte 1 | F2 | P1 | 0x00 | Buffer size high byte |
| byte 2 | F1 | P2 | 0x00 | Extended capability flags |
| byte 3 | F0 | P3 | 0x23 | CANFDX | CANOVIO | CANFC32 |
Wire bytes (hex):2A 2A 18 42 ZPAD ZPAD ZDLE ZHEX30 31 type 0x01 (ZRINIT)30 30 30 30 30 30 32 33 data bytes 00 00 00 23cc cc cc cc CRC-160D 0A CR LF11 XON
Readable: **\x18B010000002300000\r\n\x11 ^^ (type = 01) ^^^^^^^^ (00 00 00 23)The receiver retransmits ZRINIT at 10-second intervals for approximately 40 seconds before falling back to XMODEM/YMODEM.
3. ZFILE — Sender offers a file
The sender transmits a BIN32 ZFILE header. Because the receiver advertised CANFC32, the sender uses 32-bit CRC. The header carries transfer options in the flag bytes:
| Byte | Flag | Value | Meaning |
|---|---|---|---|
| F3 | — | 0x00 | Reserved |
| F2 | ZF2 | 0x00 | Transport: none (no compression/encryption) |
| F1 | ZF1 | 0x00 | Management: default |
| F0 | ZF0 | 0x01 | Conversion: ZCBIN (binary transfer) |
ZF0 conversion options: ZCBIN (1) binary, ZCNL (2) newline conversion, ZCRECOV (3) crash recovery/resume.
ZF1 management options: ZMNEWL (1) newer or longer, ZMCRC (2) different CRC/length, ZMAPND (3) append, ZMCLOB (4) clobber/replace, ZMNEW (5) newer only, ZMDIFF (6) different date/length, ZMPROT (7) protect existing.
ZF2 transport options: ZTLZW (1) Lempel-Ziv, ZTCRYPT (2) encryption, ZTRLE (3) run-length encoding.
BIN32 header — wire bytes (hex):2A 18 43 ZPAD ZDLE ZBIN3204 type 0x04 (ZFILE) — ZDLE-escaped00 00 00 01 F3=00 F2=00 F1=00 F0=01 (ZCBIN)cc cc cc cc CRC-32 of [04, 00, 00, 00, 01]All bytes after the preamble (type, flags, CRC) are ZDLE-escaped: if any byte matches a control character (0x10, 0x11, 0x13, 0x18, etc.) it is prefixed with ZDLE (0x18) and XORed with 0x40.
A ZCRCW data subpacket follows immediately, containing the file metadata as null-separated ASCII fields:
Data subpacket — wire bytes (conceptual, before ZDLE escaping):68 65 6C 6C 6F 2E 74 78 74 00 "hello.txt" NUL31 32 30 30 20 "1200 " — file length (decimal)31 34 37 34 34 31 30 37 34 30 30 20 "14744107400 " — mod date (octal seconds since epoch)31 30 30 36 34 34 20 "100644 " — Unix file mode (octal)30 20 "0 " — serial number31 20 "1 " — files remaining31 32 30 30 "1200" — bytes remaining00 NUL terminator
ZDLE-escaped subpacket terminator:18 6B ZDLE ZCRCW (0x6B) — end of frame, ACK expectedcc cc cc cc CRC-32 of [data bytes + 0x6B]The full metadata string is: hello.txt\01200 14744107400 100644 0 1 1200\0. Fields after the filename are space-separated; the filename and the final field are each null-terminated. The total subpacket must not exceed 1024 bytes.
4. ZRPOS — Receiver sets the starting offset
The receiver examines the file metadata and responds with a HEX ZRPOS header. The four data bytes carry the file offset (little-endian) at which to begin transmission — 0x00000000 for a new file:
Wire bytes (hex):2A 2A 18 42 ZPAD ZPAD ZDLE ZHEX30 39 type 0x09 (ZRPOS)30 30 30 30 30 30 30 30 offset = 0x00000000 (P0 P1 P2 P3, little-endian)cc cc cc cc CRC-160D 0A CR LF11 XONIf the receiver already has 800 bytes from a previous interrupted transfer and wants to resume, it would send offset 0x00000320 (800 decimal, little-endian: P0=0x20, P1=0x03, P2=0x00, P3=0x00):
Resuming from byte 800:...30 39 32 30 30 33 30 30 30 30 cc cc cc cc 0D 0A 11 type 20 03 00 00 as hexThe receiver may alternatively respond with:
- ZSKIP (
0x05) — skip this file entirely - ZCRC (
0x0D) — request the sender compute and return a CRC-32 of the first N bytes before deciding
5. ZDATA — Sender streams file data
The sender transmits a BIN32 ZDATA header with the file offset matching the ZRPOS value, followed by one or more data subpackets containing the file contents. For our 1200-byte file starting at offset 0, with a 1024-byte subpacket size:
BIN32 ZDATA header:2A 18 43 ZPAD ZDLE ZBIN320A type 0x0A (ZDATA)00 00 00 00 offset = 0 (P0 P1 P2 P3)cc cc cc cc CRC-32 of [0A, 00, 00, 00, 00]Subpacket 1 — first 1024 bytes, more data follows (ZCRCG):
[1024 bytes of file data, ZDLE-escaped]18 69 ZDLE ZCRCG (0x69) — more data, no ACK neededcc cc cc cc CRC-32 of [data + 0x69]Subpacket 2 — remaining 176 bytes, end of file reached (ZCRCE):
[176 bytes of file data, ZDLE-escaped]18 68 ZDLE ZCRCE (0x68) — end of frame, no ACKcc cc cc cc CRC-32 of [data + 0x68]The four subpacket types and when the sender uses each:
| Type | Value | End of frame | Response | When used |
|---|---|---|---|---|
| ZCRCG | 0x69 | No | None (errors only) | Primary streaming mode — maximum throughput |
| ZCRCQ | 0x6A | No | ZACK expected | Window management — only if receiver has CANFDX |
| ZCRCW | 0x6B | Yes | ZACK before next | Receiver has limited buffer or no CANOVIO |
| ZCRCE | 0x68 | Yes | None (errors only) | End-of-file reached mid-frame |
Between subpackets the sender samples the reverse channel for ZPAD (0x2A) or CAN (0x18). If detected, the sender closes the current frame with an empty ZCRCE and reads the receiver’s error header (typically ZRPOS). A noise counter is incremented on stray characters; overflow triggers a ZCRCW to force an explicit ZACK.
6. ZEOF — Sender signals end of file
The sender transmits a BIN32 ZEOF header with the final file offset equal to the total file size (1200 = 0x000004B0, little-endian: P0=0xB0, P1=0x04, P2=0x00, P3=0x00):
BIN32 ZEOF header:2A 18 43 ZPAD ZDLE ZBIN320B type 0x0B (ZEOF)B0 04 00 00 offset = 1200 (little-endian)cc cc cc cc CRC-32 of [0B, B0, 04, 00, 00]The receiver compares this offset against its received byte count:
- Match — the receiver closes the file and responds with ZRINIT (ready for next file).
- Mismatch — the receiver sends ZRPOS with its current offset, forcing the sender to retransmit the missing data.
- I/O error — the receiver sends ZFERR (
0x0C).
7. ZRINIT — Receiver ready for next file
The receiver sends another HEX ZRINIT header, identical in structure to step 2, signalling it is ready for the next file. The sender may loop back to step 3 with the next file in the batch.
Wire bytes (hex) — identical to step 2:2A 2A 18 42 30 31 30 30 30 30 30 30 32 33 cc cc cc cc 0D 0A 118. ZFIN — Sender ends the session
When no more files remain, the sender transmits a HEX ZFIN header with all data bytes zero:
Wire bytes (hex):2A 2A 18 42 ZPAD ZPAD ZDLE ZHEX30 38 type 0x08 (ZFIN)30 30 30 30 30 30 30 30 00 00 00 00cc cc cc cc CRC-160D 0A CR LF (no XON after ZFIN)Note: XON is deliberately omitted after ZFIN to allow clean session teardown.
9. ZFIN — Receiver acknowledges
The receiver responds with its own HEX ZFIN, structurally identical:
Wire bytes (hex):2A 2A 18 42 30 38 30 30 30 30 30 30 30 30 cc cc cc cc 0D 0A10. OO — Over and Out
The sender transmits two raw ASCII O characters (0x4F 0x4F), then exits. This is not a ZMODEM frame — just two literal bytes:
Wire bytes (hex):4F 4F "OO" — Over and OutThe receiver waits briefly for the O characters, then exits whether they were received or not.
Error Recovery
ZMODEM’s error recovery is driven entirely by the receiver:
- The receiver detects a CRC mismatch or missing data.
- If an Attn sequence was negotiated via ZSINIT, the receiver sends it to interrupt the sender’s output stream.
- The receiver sends a HEX ZRPOS header with the last known good file offset.
- The sender purges its output buffer, repositions in the file, and resumes with a new ZDATA header (using ZCRCW for the first subpacket after an error to guarantee the channel is flushed).
- Normal streaming resumes with ZCRCG subpackets.
If a header itself is garbled, the receiver sends ZNAK to request retransmission of the last header. Five consecutive CAN characters from either side abort the session entirely.
Streaming Modes
The choice of streaming mode depends on receiver capabilities:
- Full streaming with sampling (CANFDX + CANOVIO): The sender uses ZCRCG subpackets and samples the reverse channel for errors between subpackets. Maximum throughput.
- Full streaming with reverse interrupt (CANFDX, no sampling): The receiver uses the Attn sequence (break signal, control character) to interrupt the sender on error, then sends ZRPOS.
- Sliding window (CANFDX, no sampling, buffered reverse): The sender uses ZCRCQ subpackets to elicit periodic ZACKs. The sender reads buffered responses to manage window size.
- Segmented streaming (no CANOVIO): The receiver specifies a buffer size in ZRINIT. The sender fills the buffer using ZCRCW at the end of each segment and waits for ZACK before continuing. A 16 KB buffer adds roughly 3% overhead compared to full streaming at 5-second round-trip delay.