Reticulum LoRa messaging client for the Epson HX-20 (ESP32-S3 + SX1262 bridge)
  • C++ 55.6%
  • Python 28.7%
  • BASIC 14.6%
  • Shell 1.1%
Find a file
Hans Hübner 3cdd7fe2d5 MESH.BAS: correct COM0 descriptor to 48N1F (1200/8N1, no handshake)
The HX-20 BASIC baud digit for 1200 bps is 4, and flag F selects no
handshake. Updates CP$ from COM0:(58N1D) to COM0:(48N1F) and the README
verify note to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 11:56:35 +02:00
docs Bring the HX-20 client up on the live mesh: reliability, UX, serial loader 2026-07-05 17:30:45 +02:00
firmware firmware: deep-sleep power-off on a PRG-button hold 2026-07-08 11:46:41 +02:00
hx20 MESH.BAS: correct COM0 descriptor to 48N1F (1200/8N1, no handshake) 2026-07-08 11:56:35 +02:00
tools Bring the HX-20 client up on the live mesh: reliability, UX, serial loader 2026-07-05 17:30:45 +02:00
.gitignore Bring the HX-20 client up on the live mesh: reliability, UX, serial loader 2026-07-05 17:30:45 +02:00
README.md MESH.BAS: correct COM0 descriptor to 48N1F (1200/8N1, no handshake) 2026-07-08 11:56:35 +02:00

hx20-meshcore

A MeshCore node on a Heltec WiFi LoRa 32 V3.1 (ESP32-S3 + SX1262 LoRa) that presents a text messaging terminal to an Epson HX-20 over RS-232C. The 1982 laptop is the terminal; the ESP32 does the crypto, the mesh, and the radio.

⚠️ HARDWARE BRING-UP BLOCKED ON THE HX-20'S SERIAL PORT

The firmware builds clean against MeshCore v1.10 (pio run, env heltec-lora32-v3) and the ESP32→HX-20 serial direction now works on real hardware: /serve streams MESH.BAS at 1200 bps and the listing arrives intact, confirmed from both the ESP32 and a PC on a null-modem cable, so the RX/TX wiring through the level converter is good.

The HX-20's own serial port appears faulty. BASIC aborts LOAD with I/O ERROR the instant anything is transmitted to the HX-20. An LED RS-232 tester on the HX-20's COM0 output shows only the mark LED flickering while the HX-20 sends — the space LED never lights, so the port isn't swinging to the space level and never produces the full bipolar ±8 V RS-232 signal. This points to a fault in the HX-20's RS-232C line driver, upstream of anything in this repo; the serial bridge can't be exercised end-to-end until that hardware is repaired or another HX-20 is used.

The SX1262 radio link, channel messaging, and flash persistence remain unverified on hardware — read those as "should work", not "does work". The BASIC client and line protocol have been exercised on a Mac (see Testing on a Mac). Remaining things to check are in To verify on real hardware.

 Epson HX-20             MAX3232           Heltec V3.1 (ESP32-S3)       LoRa mesh
┌────────────┐  RS-232  ┌────────┐  3V3   ┌────────────────────┐  868      ((•))
│ COM0       │◀────────▶│ level  │◀──────▶│ Serial1 (GPIO47/48)│  MHz     MeshCore
│ (MESH.BAS) │  ±8V     │ shift  │ TTL    │ MeshCore node      │◀──────▶   peers
└────────────┘          └────────┘        │ SX1262             │
                                          └────────────────────┘
                                            USB-C = debug/flash

Architecture

The ESP32 is the server and owns all durable state — identity, message store, contacts, and channels — in flash. The HX-20 BASIC program is a thin client: it issues one-line requests over the serial link and renders the replies on its 20×4 LCD. This keeps the BASIC small and means nothing is lost when the HX-20 is switched off. The wire format is in docs/PROTOCOL.md.

The HX-20 client (hx20/MESH.BAS) provides:

  • Inbox / sent browsing — paged lists, open to read, page through the body, and delete (D). Both direct messages and channel posts land in the inbox.
  • Direct messages — pick a contact from the address book or type a public-key prefix, compose line by line. End-to-end encrypted to that contact's key.
  • Channels — post to and read the public channel, named (hashmark) channels like #hikers, and private channels protected by your own pre-shared key. Join, leave, and list channels from the client.
  • Address book — list / add / delete contacts (name ↔ MeshCore public-key prefix), stored on the ESP32 and auto-filled from adverts.
  • Stations — a persistent roster of every node heard advertise (menu key 7), showing each one's relay hop count and how many adverts arrived. Send your own advert (A) so peers learn your key.
  • Receive notification — the client polls STAT from its idle loop; when the unread count rises it beeps, and if auto-print is toggled on (menu key 6) it dumps the new message to the built-in printer. Any message can also be printed on demand from the read screen (P).

Hardware

Part Notes
Heltec WiFi LoRa 32 V3.1 ESP32-S3 + SX1262, 868 MHz. The V3.1 is pin-compatible with V3; MeshCore uses the same heltec_wifi_lora_32_V3 board profile. Confirm it enumerates as ESP32-S3 / SX1262 (some clones ship SX1276).
Epson HX-20 Terminal. Uses its RS-232C port (true ±8V levels), not the high-speed serial (TTL, proprietary peripheral chain).
MAX3232 board RS-232 ↔ 3.3V TTL. Required — never wire the HX-20 RS-232C straight to a GPIO.

Wiring

ESP32 J2/J3 header (3.3V) ↔ MAX3232 TTL side ↔ MAX3232 RS-232 side ↔ HX-20 COM0:

ESP32 MAX3232 TTL MAX3232 RS-232 HX-20 COM0
GPIO47 (TX) T1IN T1OUT → RXD (pin 3)
GPIO48 (RX) R1OUT ← R1IN TXD (pin 2)
GND GND GND SG (pin 1)
3V3 VCC

The HX-20 may require its handshake inputs asserted before it will transmit — if you see no data, loop RTS↔CTS (4↔5) and DTR↔DSR (7↔6) at the HX-20 connector, or drive them from spare MAX3232 channels. GPIO47/48 are suggested, free pins; verify against your board silkscreen and swap if needed (the ESP32-S3 routes UART to almost any GPIO).

MeshCore integration

The radio stack is the MeshCore C++ mesh library driving the SX1262 through RadioLib. The bridge (firmware/src/mesh_bridge.{h,cpp}) subclasses MeshCore's BaseChatMesh framework — the same layer the stock simple_secure_chat and companion_radio firmwares build on — and exposes a small send/receive API to the protocol server. Because this is a genuine MeshCore node, it interoperates with stock MeshCore devices on air: phones running the MeshCore companion app, repeaters, and room servers all see it as an ordinary client node.

MeshCore has two messaging surfaces, and this terminal uses both:

  • Direct messages (private, 1:1). Each node has an Ed25519 identity; its public key is its address. You learn a peer from its signed advert (MeshCore's discovery broadcast), after which direct messages are end-to-end encrypted to that peer's key. The address book therefore auto-populates from adverts, and a SND to an unknown peer returns no contact; await advert.
  • Channels (group). A channel is a name plus a shared key; the key's hash identifies the channel on the air. Three flavors are supported:
    • Public channel — the built-in default channel with MeshCore's well-known key. Everyone on the mesh can read and post.
    • Named (hashmark) channels#name channels whose key the bridge derives from the name, so anyone who joins #hikers lands on the same channel. MeshCore itself only knows name + PSK, so this derivation is our convention: it interoperates with other hx20-meshcore nodes, and with stock MeshCore clients only if they configure a channel with the same derived key.
    • Private channels — a name plus your own base64 pre-shared key, the same key format MeshCore clients exchange; only key-holders can read.

Adverts are sent when you start the node and on demand; MeshCore clients do not auto-repeat, so this node floods its own advert but does not relay other traffic (the "companion" role, to keep routing paths clean).

Using the HX-20 client (MESH.BAS)

The client opens on the main menu and polls the bridge while idle, so new mail announces itself with a beep wherever you are. Everything is single-keystroke; the screen is 20×4, so lists page 3 entries at a time.

  • Main menu1 Inbox · 2 Send · 3 Channels · 4 Book · 5 My address · 6 toggle auto-print · Q quit. The top line shows the unread count.
  • Inbox / Sent (1) — newest first, direct messages and channel posts mixed. 13 open an entry · N/B next/previous page · S switch received ⇄ sent · M menu. A leading # on the sender marks a channel post. Opening a message marks it read and pages the body 3 lines at a time (any key continues, M stops); at the end P prints it, M returns.
  • Send (2) — pick the recipient: 1 a contact from the address book, 2 type a public-key prefix, 3 a channel, M cancel. Then type the message a line at a time; a line with just . sends, / cancels. (A direct send fails with no contact if you haven't yet heard that peer's advert.)
  • Channels (3)N/B page · A add/join (public / #name / private with a key) · D leave by the shown number · M menu. The public channel is always present.
  • Address book (4)N/B page · A add (name, then key prefix) · D delete by the shown number · M menu. The book also fills itself in as peer adverts arrive.
  • My address (5) — shows your name and MeshCore public-key prefix; share it so others can message you. P prints it, M returns.
  • Receiving — messages are stored on the bridge; the client beeps when the unread count rises, and with auto-print on (menu 6) each new message is printed automatically.
  • Clock — at startup the client sends the HX-20's date/time to the bridge, so received messages are stamped with real time.

Bring-up, in order

  1. Fetch deps and flash. cd firmware && ./get-deps.sh && pio run -t upload && pio device monitor. It defaults to the MeshCore EU preset (869.618 MHz / BW 62.5 / SF 8); these must match the local mesh or the radio hears nothing. US users set LORA_FREQ = 910.525 (and confirm BW/SF/CR) in firmware/platformio.ini.

  2. Load MESH.BAS onto the HX-20. The ESP32 has the client program embedded and can serve it over COM0:

    • On the HX-20: LOAD "COM0:(58N1D)" and press RETURN (it now waits for the program).
    • On the ESP32's USB console: type /serve. The bridge streams MESH.BAS down COM0 (~2 min at 1200 baud), ending with Ctrl-Z.
    • On the HX-20: RUN.

    Set the baud digit in CP$ to match HX20_BAUD (start at 1200). Cassette still works too. Whenever hx20/MESH.BAS changes, pio run -t upload re-embeds it, so /serve always sends the current version.

  3. Test without a peer. Type a line into the USB serial monitor — the firmware injects it as a fake inbound message; the HX-20 should beep (and print, if enabled). Every screen works against the live (empty) store.

  4. Test with a peer. Bring up any MeshCore node — a phone running the MeshCore app paired to a companion radio, or a second board. Post to the public channel from either side, or once its advert is heard send it a direct message from the HX-20.

Testing on a Mac (no HX-20)

You can run the client, hx20/MESH.BAS, on a Mac and point it at either the real firmware (your LoRa32 over USB) or a fake server. The client is identical; only its backend changes. One launcher wires it up:

 MESH.BAS ──TCP──▶ ┌ mock-server.py         (fake server: no board, no radio)
 (PC-BASIC/MAME)   └ hx20-emu-bridge.py ──USB──▶ LoRa32 running the firmware
python3 tools/testenv.py mock                              # fake server
python3 tools/testenv.py mock --inject 20                  # ...deliver mail after 20s
python3 tools/testenv.py firmware --device /dev/cu.usbserial-XXXX   # real LoRa32
  • Fake servertools/mock-server.py speaks the whole docs/PROTOCOL.md protocol, seeded with contacts, the Public/#hikers channels and a few messages. --inject delivers a new message so you can watch the beep / auto-print fire.
  • Real firmware — the launcher runs tools/hx20-emu-bridge.py, which puts the flashed LoRa32 into its USB /hx20 server mode and bridges it to the client.

The client runs under PC-BASIC by default — ROM-free; the launcher generates a GW-BASIC-dialect variant of MESH.BAS on the fly (COM0→COM1, one bidirectional handle). PC-BASIC needs Python ≤3.12 (the stdlib chunk module it uses is gone in 3.13):

python3.12 -m venv .venv && .venv/bin/pip install pcbasic

For the faithful 20×4 LCD / printer behavior, --front mame runs the real Epson HX-20 ROM instead (needs MAME and the copyrighted ehx20 ROM set). Full details and the manual, non-launcher invocations are in tools/README.md.

Status

  • HX-20 BASIC client — inbox/sent, direct send, channels, address book, 20×4 paged UI, beep + auto-print.
  • Serial protocol + ESP32 server (docs/PROTOCOL.md), extended for channels.
  • Firmware builds — pio run (env heltec-lora32-v3) links clean against MeshCore v1.10, consuming it as a library; mesh_bridge.cpp subclasses BaseChatMesh, radio/board/RTC come from MeshCore's heltec_v3 target.h. Flash 28%, RAM 7%. Not yet run on device.
  • Direct + channel messaging — direct messages use sendMessage, channel posts use sendGroupMessage; public channel uses MeshCore's default PSK. Named (hashmark) channels derive their PSK from the name (our convention).
  • ⚠️ Identity + contacts persistence — the MeshCore identity is stored via IdentityStore and contacts are cached to /contacts, so the address and known peers survive reboots.
  • App-store persistence — messages, contacts, and channels saved to LittleFS (/hx20store.txt) on every change, restored on boot; inbox capped at the last 100.
  • Real timestamps — the HX-20 sends its RTC at startup via TIME; messages are stamped MMDD HHMM (uptime until the first sync).

To verify on real hardware

  • HX-20 serial port fault (current blocker). The HX-20's COM0 output never reaches the space level (only the mark LED flickers on an RS-232 tester), so it can't be driven end-to-end. Repair the HX-20's RS-232C driver or use another unit before re-running the bring-up.
  • Serial1 / GPS pins — confirmed. MeshCore's heltec_v3 variant defaults its GPS UART to GPIO47/48, the pins we use for the HX-20 on Serial1. The MESH.BAS stream reaches the wire intact, so platformio.ini redirecting the GPS pins and main.cpp claiming Serial1 last does keep the port for the HX-20.
  • Pin the MeshCore ref. The build tracks MeshCore v1.10 (main at fetch time). Pin REF in get-deps.sh to that commit so a later upstream change can't silently break the build flags in platformio.ini.
  • Hashmark channel interop. Named channels derive their PSK from the name via the bridge (hashmarkPsk), which is a convention, not MeshCore's. Two hx20-meshcore nodes agree; to share a #name channel with a stock MeshCore client, configure that client with the same derived key (or use a private channel with an explicit shared PSK).
  • DATE$ field orderMESH.BAS assumes the HX-20 returns YY/MM/DD; if yours gives MM/DD/YY, reorder before sending TIME (flagged in the client).
  • Filesystem coexistence — the app store uses LittleFS; confirm MeshCore's own persistence mounts the same filesystem and does not clash.
  • The COM0: descriptor in MESH.BAS (CP$) is COM0:(48N1F) — baud digit 4 = 1200 bps (matches HX20_BAUD), 8/N/1, flag F = no handshake.
  • HX-20 BASIC niceties (CLS, INKEY$, LINE INPUT, LPRINT, CHR$(7) beep) — should be fine but worth a pass on an emulator.
  • Whether 4800 baud is reliable with BASIC's per-char INPUT$ polling, or to stay at 1200.

Layout

docs/PROTOCOL.md      serial line protocol (the HX-20 ↔ ESP32 contract)
hx20/MESH.BAS         HX-20 BASIC client
firmware/             PlatformIO project for the Heltec V3.1
  platformio.ini
  get-deps.sh         vendors MeshCore into lib/
  src/main.cpp        protocol server + message/contact/channel store
  src/mesh_bridge.*   MeshCore + SX1262 init, direct/channel send/receive, adverts
tools/                Mac test environment (run MESH.BAS with no HX-20)
  testenv.py          launcher: MESH.BAS ↔ fake server or real firmware
  mock-server.py      fake server speaking the line protocol
  hx20-emu-bridge.py  bridge a client to the LoRa32's USB /hx20 server