- C++ 55.6%
- Python 28.7%
- BASIC 14.6%
- Shell 1.1%
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> |
||
|---|---|---|
| docs | ||
| firmware | ||
| hx20 | ||
| tools | ||
| .gitignore | ||
| README.md | ||
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, envheltec-lora32-v3) and the ESP32→HX-20 serial direction now works on real hardware:/servestreamsMESH.BASat 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
LOADwithI/O ERRORthe 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
STATfrom its idle loop; when the unread count rises it beeps, and if auto-print is toggled on (menu key6) 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
SNDto an unknown peer returnsno 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 —
#namechannels whose key the bridge derives from the name, so anyone who joins#hikerslands 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 menu —
1Inbox ·2Send ·3Channels ·4Book ·5My address ·6toggle auto-print ·Qquit. The top line shows the unread count. - Inbox / Sent (
1) — newest first, direct messages and channel posts mixed.1–3open an entry ·N/Bnext/previous page ·Sswitch received ⇄ sent ·Mmenu. 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,Mstops); at the endPprints it,Mreturns. - Send (
2) — pick the recipient:1a contact from the address book,2type a public-key prefix,3a channel,Mcancel. 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/Bpage ·Aadd/join (public /#name/ private with a key) ·Dleave by the shown number ·Mmenu. The public channel is always present. - Address book (
4) —N/Bpage ·Aadd (name, then key prefix) ·Ddelete by the shown number ·Mmenu. 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.Pprints it,Mreturns. - 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
-
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 setLORA_FREQ = 910.525(and confirm BW/SF/CR) infirmware/platformio.ini. -
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 streamsMESH.BASdown COM0 (~2 min at 1200 baud), ending with Ctrl-Z. - On the HX-20:
RUN.
Set the baud digit in
CP$to matchHX20_BAUD(start at 1200). Cassette still works too. Wheneverhx20/MESH.BASchanges,pio run -t uploadre-embeds it, so/servealways sends the current version. - On the HX-20:
-
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.
-
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 server —
tools/mock-server.pyspeaks the whole docs/PROTOCOL.md protocol, seeded with contacts, thePublic/#hikerschannels and a few messages.--injectdelivers 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/hx20server 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(envheltec-lora32-v3) links clean against MeshCore v1.10, consuming it as a library;mesh_bridge.cppsubclassesBaseChatMesh, radio/board/RTC come from MeshCore'sheltec_v3target.h. Flash 28%, RAM 7%. Not yet run on device. - ✅ Direct + channel messaging — direct messages use
sendMessage, channel posts usesendGroupMessage; 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
IdentityStoreand 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 stampedMMDD 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_v3variant defaults its GPS UART to GPIO47/48, the pins we use for the HX-20 on Serial1. TheMESH.BASstream reaches the wire intact, soplatformio.iniredirecting the GPS pins andmain.cppclaiming Serial1 last does keep the port for the HX-20. - Pin the MeshCore ref. The build tracks MeshCore v1.10 (
mainat fetch time). PinREFinget-deps.shto that commit so a later upstream change can't silently break the build flags inplatformio.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#namechannel 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 order —MESH.BASassumes the HX-20 returnsYY/MM/DD; if yours givesMM/DD/YY, reorder before sendingTIME(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 inMESH.BAS(CP$) isCOM0:(48N1F)— baud digit4= 1200 bps (matchesHX20_BAUD), 8/N/1, flagF= 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