Phase 10 - WebSocket Terminal Console¶
Goal: use CP/M in a web browser over the Pico 2 W's Wi-Fi. USB remains the fallback terminal.
- Start here: complete the Phase 9 automatic CP/M loader.
- Unchanged: 120 MHz Pico clock, 20 MHz scope-measured Z80 clock, BIOS and disk layout.
- Hardware: onboard Wi-Fi, the 3.3 V SSD1315 status OLED and optional ATtiny85 speaker. PCB changes remain deferred.
First connection and USB provisioning¶
See Related projects for building C applications with DCC, importing application disks from Altair 8800 AI Edition, and deploying them with the Z80ROMlessSBC VS Code disk tasks.
1. Deploy from VS Code¶
Before any update
Save CP/M work. Apply external +5 V before USB and keep it applied. Never power the populated board from USB alone. Disk updates overwrite selected drives without backups.
Open the Command Palette, run Tasks: Run Task, then choose:
| Update | Exact task name | What changes |
|---|---|---|
| Firmware | Build and flash Pico - Phase 10 firmware | Preserves disks and ENV settings |
| CP/M system | Build and flash Pico - CP/M system and Drive A | Rebuilds CP/M; replaces boot package and Drive A; preserves firmware, B-D and ENV |
| All disks | Flash Pico - All disks (A-D) | Uses src/disks/native/, matching the emulator; rebuilds A's system and replaces boot package and A-D, including D's database; preserves firmware and ENV |
| CP/M system disk | Flash Pico - Drive A | Replaces A with a regenerated native image; checks boot compatibility |
| BDS C disk | Flash Pico - Drive B | Replaces B only |
| Escape disk | Flash Pico - Drive C | Replaces C only |
| Blank disk | Flash Pico - Drive D | Replaces D only |
- Firmware confirmation: Proceed. Single-disk confirmation: Replace selected disks. All-disks confirmation: Replace all disks. All default to Cancel.
- CP/M system confirmation: Replace CP/M and drive A, default Cancel. Use this after changing the CP/M assembly; it updates cold-boot and warm-boot images together. All Drive A files are replaced without backups.
- Firmware and disk tasks force-update without a previous-firmware handshake; save CP/M work first. All tasks verify written bytes and request reboot.
- Disk tasks do not rebuild firmware or applications. Install updated applications into the source disk first.
- Setup/details: firmware build and provisioning and VS Code disk tasks.
Phase 10 firmware deployed.
First checkout or manual build
Initialize the WebSocket submodule before building or running the firmware task:
git submodule update --init --recursive
cmake -S . -B build -G Ninja -DPICO_BOARD=pico2_w
cmake --build build --target z80_stage10_websocket_terminal -j
Output: build/src/stage10_websocket_terminal/z80_stage10_websocket_terminal.uf2.
These commands build only; they do not deploy. Flash verification proves
written bytes, not a successful CP/M boot.
2. Set Wi-Fi over USB¶
- Open the USB terminal. CP/M starts without waiting for Wi-Fi or a browser.
- Press Ctrl-] c to open configuration. Missing/invalid Wi-Fi credentials open it automatically.
- Select 1 - Wi-Fi, then C. Enter your SSID and password.
- Press Q to return to the main menu, then Q again to exit.
Saved changes trigger a safe reboot. No changes means no reboot. A storage error cancels reboot and is reported. Save application work first: flushing disks cannot save unsaved application RAM.
3. Open the browser terminal¶
- Press Ctrl-] s on USB to display the Pico's address.
- Open
http://<pico-ip>/. HTTP and WebSocket share default port 80. -
Press Enter for a fresh CP/M prompt, then try
DIR. -
Pico address verified and terminal page opened.
- Use a trusted local network only. The terminal has no authentication or TLS; never expose port 80 to the Internet.
- The browser needs Internet access on first use to load xterm.js dependencies.
- Reload the terminal page after firmware updates.
Unified system configuration¶
Ctrl-] c opens setup over USB, even while a browser owns the terminal.
It saves the same environment variables that
you can edit from CP/M with C:ENV.
| Menu | Use it for |
|---|---|
| 1 - Wi-Fi | C sets SSID/password; U clears them |
| 2 - Remote filesystem | Stores FS_SERVER only; remote file access is not implemented or required for Wi-Fi |
| 3 - Chat | 1 OpenAI, 2 compatible endpoint, 3 Ollama; 4-6 model, token limit and temperature; 7 thinking |
| 4 - OpenWeatherMap | C sets API key, location and units |
| 5 - Timezone | C sets POSIX TZ; for example, AEST-10 means UTC+10 |
| Key | Action |
|---|---|
| Enter | Keep the existing value |
| - | Clear the value; clearing the Wi-Fi password selects an open network |
| ESC | Cancel the current unsaved edit |
| S | Show settings, with secrets masked |
| Q | Return to the main menu; at the main menu, exit |
Settings persist in plaintext onboard flash, not encrypted storage. Passwords and API keys are hidden during entry and masked on display.
Validation, saved changes and service settings
- Wi-Fi: SSID up to 32 bytes; WPA2 password 8-63 bytes or a 64-digit hexadecimal key.
- Saving: each key is atomic, but a multi-field edit is not. After a failed save, reopen setup and retry affected fields. Unrelated keys are preserved.
- Disconnect: unsaved edits are discarded; saved edits still require reboot when exiting setup. Viewing settings does not count as a change.
- ENV: values are stored literally. Network ENV edits apply on reboot or setup reload; saved Wi-Fi edits can reconnect immediately.
- Chat: custom/Ollama endpoints need scheme, host and path, for example
http://192.168.1.26:11434/v1/chat/completions. Only OpenAI receivesCHAT_OPENAI_KEY; custom-provider authentication is unsupported. Model/options are consumed by the CP/M application. See CHAT configuration. - Weather: location can be
Sydney,AUorlat=-33.8&lon=151.2; units aremetric,imperialorstandard. - Timezone: empty means UTC;
AEST-10AEDT,M10.1.0,M4.1.0/3includes daylight saving. UseTZ, notUTC_OFFSET.
Troubleshooting¶
| Symptom | Check or action |
|---|---|
| No Pico address | Ctrl-] s shows ip=unavailable; check Wi-Fi settings. Association times out after 30 seconds and retries after 5 seconds. |
| Page opens, but no prompt | Press Enter. Old output is not replayed to a new browser. |
| CP/M still does not respond | Use Ctrl-] s to inspect boot evidence, disk errors, queue drops and browser ownership. A working web page does not prove Z80 operation. |
| USB ignores normal typing | A browser owns CP/M. Close it to return to USB; USB supervisor commands still work. |
| Previous browser disconnects when another connects | Last connection wins: the new browser owns the terminal and the previous socket is disconnected. |
| Browser disappears | USB ownership returns on disconnect/Wi-Fi loss, or after five missed pongs; pings are sent every five minutes. The Z80 keeps running. |
| Wrong address | Use the Pico's address, not FS_SERVER. HTTP/WebSocket use default port 80; ?altair=<pico-ip> changes the host only. |
| Configuration save fails | Reopen Ctrl-] c, inspect saved values and retry affected fields. |
Confirm boot and network faults separately.
Ctrl-] s also shows uptime, reset causes and OLED status without injecting periodic diagnostics into CP/M output.
SSD1315 status OLED¶
The 0.91-inch, monochrome 128x32 I2C OLED shows two lines and a heartbeat:
- IP address:
IP: 192.168.1.10, checked once per second through a lock-free change counter; the rendered bitmap is cached and redrawn only when the address changes. With no address it showsIP: OFFLINE. - Uptime: the second line starts at the left edge with
U: HH:MMfor the first day (up toU: 23:59), then days, for exampleU: 4d 05:23. The uptime is redrawn once per uptime minute; only changed column spans are sent. The default HTTP port needs no display label. TheU:label fits through eight day digits; with nine day digits it is dropped so the clock still fits throughout the 64-bit microsecond range.
Text uses 3x5 glyphs at double size with proportional spacing: . and : take
one pixel column, so the longest address, IP: 255.255.255.255, fits the
128-pixel width exactly.
- Heartbeat: a 2x2 dot in the bottom-right corner toggles once per second
while the Z80 runs. It shows display-service activity, not CP/M application
health or a latency measurement.
- Web terminal: while a browser owns the web terminal, a second 2x2 dot
two pixels to the left blinks with the heartbeat. The display reads the
console router's connected flag, one atomic load, only on lit ticks, so a
connect or disconnect shows within two seconds. Without a browser, the
heartbeat write is unchanged at two pixel bytes; with one, both dots share
a single six-byte write.
Contrast is reduced to 0x40 from the controller's 0x7f reset level to slow
burn-in of the static layout. The display holds its last image while the
supervisor stops the Z80, loads SRAM or pauses storage work.
| OLED marking | Pico connection | J2 pin |
|---|---|---|
| VCC | 3V3, physical pin 36 | 3 |
| GND | Common GND | 4 |
| SCK (I2C SCL) | GP26, physical pin 31 | 2 |
| SDA | GP22, physical pin 29 | 1 |
Follow the module's printed labels, not connector order; the module prints
SCK for the I2C clock. Small I2C OLED modules do not share a pin order:
this module starts GND, VCC, while many others start VCC, GND. Swapping
them reverses the supply and the display does not answer its address. The
external module is schematic A2, with no PCB
footprint. The module carries its own 3.3 V regulator and 4.7 kOhm I2C
pull-ups, which must go to 3.3 V, never 5 V: with the module powered, check
that SDA and SCK idle no higher than 3.3 V before connecting them to the Pico.
Powered from J2's 3.3 V, the module shares the Pico regulator's remaining
current budget. Disconnect power safely before rewiring. PCB migration remains
deferred.
oled_display.cpp
runs on core 0 after speaker service and scans only 0x3c and 0x3d; this
module answers at 0x3c. Do not attach another device at either address. The
SSD1315 uses the SSD1306 command set: initialization selects a 32-row
multiplex, sequential COM pins and the internal charge pump. The panel stays
off until all four initial RAM pages have been acknowledged.
Two persistent 512-byte bitmaps hold desired and confirmed pixels. The IP
address occupies pages 0-1 and the uptime pages 2-3. The cached IP field is
not rasterized on every tick, and only a changed field's pages are compared;
each dirty page sends only its first-to-last changed column span. Each page is
one I2C transaction: three Co=1 control/command pairs set the page and
column, then a 0x40 control byte starts the pixel data. A tick with no change
reads only the address-change counter, taking no lock and copying no text, and
sends only the heartbeat: one 9-byte transaction
carrying two pixel-data bytes (13 bytes carrying six when the web-terminal
dot also changes). The dots sit in rows 30-31, below the
uptime field, and change only their own pixel bits. When a page completes, the next page
starts in the same core-0 pass. Core 0 owns the I2C bus, so display work never
waits on network or flash work; it adds only a bounded check to the main loop
and no work to the Z80 I/O path.
The service code is kept short on core 0. The display and I2C service paths run from RAM, so an XIP cache miss never lengthens a pass. An idle pass makes one 32-bit timer read (the speaker makes none) and returns. Glyphs are pre-scaled column masks that are shifted and OR-ed into two pages, fields clear with masked word writes, and numbers are formatted by hand. Uptime counts ticks, so the 64-bit division runs only when the clock resynchronises. A page's changed column range is found once, four bytes at a time, when it is rendered. The I2C transfer configures its DMA channels once, starts with four register writes, checks only the RX channel and verifies every ACK with one OR-accumulated word.
Every display write, from the address scan through page data, is asynchronous: it starts the transmit and receive DMA channels and returns, and a later pass collects the result without waiting. The CPU never writes the PIO FIFO. On the idle passes between ticks, which are almost every core-1 pass, the service reads the RAM-resident timer once and returns; it makes no transport call, takes no lock and publishes no status. The network side likewise compares the binary IPv4 address each second and formats text only when it changes.
New transactions require a running CPU and no flash pause. An in-flight
transfer is drained through STOP while paused, but further pages wait until
updates are allowed. Missed ticks coalesce to the current status; the service
does not queue a catch-up burst. Ctrl-] s reports the OLED address, baud,
fault, and transfers without periodic terminal logging.
transfers counts acknowledged I2C transactions.
PIO/DMA I2C transport and host test
display_i2c.cpp owns one PIO0 state machine and one TX/RX DMA pair. One DMA channel feeds encoded command words to PIO; the second collects each byte ACK and the final STOP result. No heap allocation, DMA interrupt, per-byte CPU service or busy-wait is used. A new transaction cannot start, and the persistent command/result buffers cannot be reused, until the owning service consumes the completion result. TX completion alone does not confirm STOP; RX must also finish and contain the STOP result.
A display NACK disables the OLED and records the error. Unavailable PIO/DMA resources, missing ACK/STOP results, or a DMA timeout also release the state machine, DMA channels and pins. The timeout is 5 ms plus 120 us per byte, several times the 400 kHz byte time, so slave clock stretching and a full 135-byte page transaction fit. No Z80 bus GPIO or core-0 handshake is used.
display_i2c.pio uses open-drain pins with clock stretching. The bus has one fixed rate, 400 kHz Fast-mode, for the OLED and the speaker. Each data bit uses seven PIO cycles LOW and five HIGH, giving nominal 1.46 us LOW and 1.04 us HIGH. Verify rise time at the module is at most 300 ns with the fitted pull-ups and wiring.
cmake --build build/host-control --target oled_display_test -j
ctest --test-dir build/host-control --output-on-failure -R '^oled_pio_SSD1315$'
Native tests execute the assembled PIO program and verify both scanned addresses, the 400 kHz data-bit rate, Fast-mode SCL HIGH/LOW and bus-free timing, the 128x32 initialization (multiplex, COM pins, charge pump, contrast), initial RAM writes before display-on, single-transaction page writes, same-pass chaining, cached IP rendering, the idle fast path (one timer read, no lock, no status re-publish), counter-gated address reads, exact two-byte heartbeat writes that preserve neighbouring pixels and survive uptime redraws, the web-terminal dot (one-write blinking, lit-tick-only browser reads, disconnect clearing and pause), full-width uptime formatting, minute-only uptime writes limited to pages 2-3, the longest IPv4 address, pause gating, NACKs on every byte of a maximum-length write, and transport timeout/resource failures with cleanup.
On the bench, this SSD1315 module answered at 0x3c, completed
initialization and every page write, and displayed the IP address and
uptime. Host tests do not qualify physical waveform margins, module
electrical compatibility, or core-0 latency.
ATtiny85 speaker¶
The optional Stage 11 ATtiny85 sound engine shares the OLED bus at 7-bit address 0x42. It generates tones on PB1 independently of the Pico and Z80; it is not an audio waveform streaming interface.
With all power disconnected, connect the programmed ATtiny85 DIP-8:
| ATtiny85 pin | Connection |
|---|---|
| 1 / PB5 RESET | 10 kohm pull-up to 3.3 V |
| 4 / GND | SBC common ground |
| 5 / PB0 SDA | GP22 (Pico physical 29), OLED SDA / J2 pin 1 |
| 6 / PB1 OC1A | 1 kohm to 2N3904 base; use the current-limited speaker driver linked below |
| 7 / PB2 SCL | GP26 (Pico physical 31), OLED SCL / J2 pin 2 |
| 8 / VCC | Pico 3V3(OUT); 100 nF bypass to pin 4 close to the chip |
Leave PB3/PB4 unconnected. Use 3.3 V power and SDA/SCL pull-ups only. The OLED module provides bus pull-ups; if absent, fit 4.7 kohm from each bus line to 3.3 V. Do not copy the standalone test's GP20/GP21 assignment: those are Z80 address inputs on the SBC. Never connect an 8 ohm speaker or magnetic buzzer directly to PB1. Use the Stage 11 current-limited speaker driver: 8 ohm / 0.25 W speaker, 200 ohm series resistor, 2N3904, 1 kohm base resistor, 10 kohm base pull-down, 1N4148 flyback diode and supply bypass. The speaker supply is 3.3 V, not 5 V; PB3 / pin 2 stays unconnected. Restore SBC power with external +5 V first and USB second; disconnect USB first.
The speaker bridge
implements port 0x15 with unchanged
DXSVC packets. Core 0 collects bounded packets and reads latched RAM snapshots.
Core 0 owns every I2C operation, for the speaker and the OLED. Commands are
fire and forget: each is written once, with no read-back, retry or fault, and
writes are paced 500 µs apart because the ATtiny85 has a single command slot.
A command it refuses (NACK) or that finds the bridge queue full is dropped and
counted. A status request reads the device, re-reading while a command is still
pending, and releases the waiting Z80 IN with that status. The transfers are PIO and
DMA, so core 0 never blocks on them, and core 1's network and flash work cannot
delay a deferred reply. PIO keeps WAIT asserted while the reply is outstanding. A reply
still outstanding after 100 ms is released with an invalid status and sound is
disabled with a [speaker] FAIL: bridge service stalled message, well inside
the 500 ms I/O watchdog.
The bus uses one shared PIO0 state machine and TX/RX DMA pair, with no IRQ flags. Its 26 instructions fit beside the four-instruction Z80 clock. OLED and speaker transfers both run at 400 kHz with slave clock stretching. Each service consumes only its own transfer completion, and bus buffers remain owned through STOP. Pending speaker work drains before a stop is accepted. Core 0 keeps servicing the speaker and the deferred IN reply while it waits for BUSACK; without that, a disk flush could wait on a Z80 cycle held for a reply that nothing was completing. A flush that locks core 0 out extends the transfer's deadline by the time lost. No device scan or periodic sound poll runs when sound is unused; a missing speaker does not prevent CP/M or OLED use.
Build the Phase 10 target, then use the normal authorized firmware and disk
deployment procedures. B:SOUND exercises all primitives, 63 presets and
feedback through the same SDK used in the emulator. Ctrl-] s reports
speaker fault, transfer and dropped-command counts. A bus failure, protocol
mismatch or stalled bridge disables sound with an explicit diagnostic; restart
firmware after correcting the fault.
Native tests execute the PIO instructions (read ACK/NACK, timing and clock stretching), exercise bridge packets against the real ATtiny85 engine, and check deferred replies, queue bounds, snapshot consistency, bus sharing and flash-pause routing. These are host results: shared-bus electrical margins, audible playback and Z80 wait latency require bench validation.
Network time and CLOCK services¶
- Wi-Fi enables automatic time sync with
pool.ntp.org; until then, calendar queries returnCLOCK_UNSYNCED. - Set
TZtoAEST-10for UTC+10 without daylight saving. Use the USB menu or C:ENV literal insert. Direct ENV changes apply on the next request; empty means UTC. - Look up time-zone abbreviations and UTC offsets on Wikipedia, or use this city-to-POSIX TZ lookup for complete rules. Enter the POSIX string, not the city name; its offset sign is reversed (
-10means UTC+10).
System environment variables¶
One settings store, two ways to edit it:
- USB configuration: press Ctrl-] c and choose a menu. It validates and saves the ENV values below.
- CP/M command line: run
C:ENVto list settings, orC:ENV NAME=VALUEto update one.
| Used by | ENV variables | Purpose / values |
|---|---|---|
| Wi-Fi | WIFI_SSID, WIFI_PASSWORD |
Network name and password; empty password selects an open network |
| Remote filesystem | FS_SERVER |
Server IPv4 address; stored only, remote file access is not implemented |
| Chat connection | CHAT_PROVIDER, CHAT_ENDPOINT, CHAT_OPENAI_KEY |
openai, compatible or ollama; full URL for custom/local providers; API key for OpenAI only |
| Chat options | CHAT_MODEL, CHAT_MAX_TOKENS, CHAT_TEMPERATURE |
Model name, positive token limit and temperature 0-2 |
| Chat thinking | CHAT_THINK |
Chat menu 7: false (off), true (on/medium) or default (server choice); CHAT 1.7-c11 compatible/Ollama requests only |
| Weather | OWM_KEY, OWM_LOCATION, OWM_UNITS |
API key, city/country or coordinates, and metric (default), imperial or standard |
| Local time | TZ |
POSIX timezone string; empty means UTC |
Update from CP/M¶
C:ENV -H
C:ENV OWM_UNITS
C:ENV OWM_UNITS=metric
These show help, read one value and update it. ENV listings may reveal passwords and API keys. Both interfaces save to plaintext onboard flash.
Values containing + or -: prefer the USB menu, which saves literal
text. The ENV app can interpret ordinary assignments as arithmetic. Use
C:ENV -I NAME=VALUE for a literal insert; if the key already exists, delete
it first with C:ENV -D NAME. This matters for keys, model names, coordinates
and timezone rules.
When changes take effect¶
- USB menu: exiting after saved changes safely reboots; save CP/M work first.
- C:ENV Wi-Fi settings: reboot or reload the configuration shell.
- C:ENV timezone/weather: timezone applies on the next request; weather reloads on the ENV commit, without reboot.
- C:ENV chat settings: provider/endpoint/key and CHAT 1.7-c11 thinking settings apply on the next request/turn; restart CHAT after changing model, token limit or temperature.
Weather needs Wi-Fi and synchronized time; missing settings do not stop CP/M. For service-specific setup and errors, see chat, weather and the ENV command reference.
Test plan¶
Tick only after recording hardware results. A successful build or host test is not hardware qualification.
- Boot: test USB-only, then no USB/no Wi-Fi. Reconnect USB; confirm BIOS/CCP evidence and no fatal error.
- Ownership: run browser
DIR; no duplicate USB output. USB Ctrl-] s still works. Connect a second browser: the first disconnects and the second owns CP/M. Repeat rapidly; old input/output must not replay and late closes must not disconnect the newest browser. - Traffic: sustain output and paste over 128 bytes. Check order, zero drops/faults and no duplicate output when ACKs pause.
- Keys: test Ctrl+C, Ctrl+Z, Ctrl+M/Enter, ESC, arrows, Insert/Delete and Page Up/Down. Ctrl+M is carriage return. On a tablet, test the Keys pad: the arrows send the same WordStar cursor codes (Ctrl-E/S/D/X), held keys repeat, taps do not raise the on-screen keyboard, and the floating pad can be dragged by any non-key part of the panel and keeps its position after rotation.
- Disks: repeat Phase 9 sentinel, A-D, warm-boot and copy tests through the browser.
- Recovery: disconnect browser/Wi-Fi during output and writes; verify USB fallback and no stale replay. Suspend the browser computer; verify missed-pong counting and fallback after the third consecutive missed response. Without a browser, Wi-Fi changes must preserve USB queues.
- Settings: reboot and verify persistence. Reject invalid/overlong values, hide secrets and report failed saves.
- Weather: run CLOCK with Wi-Fi/time ready; verify conditions, units and forecast while typing and using disks. Drop Wi-Fi: cached values remain readable with increasing age. Test invalid/missing keys and recovery without reboot.
- Performance: compare CLOCK redraws and active-play BREAKOUT response. Record bytes/second, key-to-echo latency and maximum network-poll gaps during reads, cached writes and flash commits.
What host tests establish
- Console tests cover concurrent 256 KiB transfers, handover and queue bounds.
- Terminal tests cover last-connection-wins takeover, stale callbacks, ordering, rejection of repeated or out-of-order frames, sequence wrap, backpressure without retransmission, cumulative ACKs, 1 ms batching and the four-frame window.
- With
PICO_SDK_PATH, WebSocket tests use the actual server with fake lwIP and real Mbed TLS, including callbacks that send, close or abort and the absence of per-message heap use; weather tests also verify the firmware TLS configuration and bundled CA. - Weather tests cover all 15 fields, caching, cancellation, malformed responses and failures.
- These prove software behavior, not wireless throughput or hardware latency. See firmware testing.
Pass gate¶
- Browser remains responsive at the baseline clock.
- Status matches queued bytes and active transport.
- Interactive commands, warm boot and A-D copies pass.
- No HTTP, WebSocket or Wi-Fi work runs on core 0.
- No unexplained drops, trap faults or storage errors.
- Complete Phase 9 final acceptance.
Core ownership and terminal routing¶
Developer reference: expand only when changing or diagnosing firmware.
Core responsibilities and terminal ownership
- Core 0: Z80 GPIO/clock/traps, journal recovery, validated SRAM loading, queued supervisor requests, shared OLED/speaker I2C and flash quiescence. No USB endpoint calls.
- Core 1: USB, runtime storage, ENV, services, Wi-Fi and HTTP/WebSocket. Disk work runs before network work; networking uses
pico_cyw43_arch_lwip_poll. - Queues: 128-byte console RX, 512-byte TX, four supervisor entries and a 4 KiB diagnostic ring (128-byte drain/pass). Short locks and atomic state publication separate cores.
- Ownership: one browser replaces ordinary USB I/O; USB supervisor/setup stays available. Last connection wins: a new WebSocket disconnects the previous browser and starts a fresh protocol session. Switching transports or browsers clears queued input/output; old output and unconfirmed input are not replayed. Wi-Fi changes alone do not clear USB-owned queues.
- Disconnected: output is discarded and TX-ready stays set. Intentional discards are distinct from reported queue overflows.
- Ports:
0x00data;0x01bit 0 RX-ready, bit 1 TX-room/disconnected, bit 7 active transport. Storage/service ports and safe stop/flush are unchanged.
Module boundaries¶
Which module owns what?
| Module | Responsibility |
|---|---|
network_runtime.cpp |
Wi-Fi lifecycle, CYW43 polling, IPv4 publication and SNTP; no terminal queues |
web_terminal.cpp |
HTTP/WebSocket sessions, heartbeat and flow control; no Wi-Fi configuration |
network_services.c |
Schedules network, weather and chat services |
chat.c, chat_stream.c, chat_network.cpp |
Bounded chat ABI, HTTP/SSE parsing and asynchronous DNS/TCP/TLS; core boundary is the service mailbox |
terminal_queue.c |
Transport-neutral queues and ownership |
usb_console.c |
Core-1 USB/alarm initialization and bounded stdout routing |
Wi-Fi disables power saving after association; failure drops the link and retries. IPv4 publication refreshes once per second. Chat never touches terminal queues, USB input or Z80 GPIO.
Acknowledged terminal protocol, version 5¶
Protocol and buffer limits
Use the served browser page, not a raw-byte WebSocket client. A page speaking any other version is rejected. Messages start with a one-byte type and four-byte little-endian sequence; data sequences start at 1 and wrap modulo 2^32. TCP delivers every frame once and in order, so nothing is retransmitted: a repeated, skipped or unexpected frame is a protocol error that disconnects the browser.
| Type | Direction | Sequence / payload |
|---|---|---|
| 5 HELLO | Pico to browser | Version 5 / none |
| 6 READY | Browser to Pico | Version 5 / none |
| 1 OUTPUT | Pico to browser | Output sequence / 1-512 bytes |
| 3 OUTPUT_ACK | Browser to Pico | Last consumed output sequence / none |
| 2 INPUT | Browser to Pico | Input sequence / 1-64 bytes |
| 4 INPUT_ACK | Pico to browser | Accepted input sequence / none |
| 7 REBOOT | Browser to Pico | Zero / none |
| 8 REBOOT_STATUS | Pico to browser | Status code / none |
Reboot Z80-Nexus: the toolbar button asks for confirmation, then sends a control frame directly to the Pico supervisor, independently of CP/M input and its queue. The supervisor drains outstanding disk/service work, flushes storage, holds RESET and stops the clock, then reboots the Pico after one second. Terminal networking stays active while disk/application workers are paused, so restart and error statuses can still be delivered. Before reboot, the supervisor sends the restarting status and explicitly closes the WebSocket; this starts browser reconnection without waiting for a TCP timeout. Startup reloads and verifies CP/M. Wi-Fi disconnects briefly; the browser automatically reconnects for this operation and clears reboot progress as soon as the terminal handshake completes and controls are re-enabled. The supervisor reports CP/M ready after observing BIOS and CCP boot markers. Unsaved application data is lost; flushing preserves only writes already received by the Pico.
The browser distinguishes Waiting for Pico supervisor (request not yet acknowledged) from Flushing disks (the supervisor has begun the stop). Neither message proves that flash is currently being written.
Storage failure cancels automatic reboot. Core 0 cancels a stop/restart after 30 seconds. Independently, core 1 expires an unfinished request after 35 seconds, including when core 0 stops servicing its loop. That deadline survives browser disconnects; retries cannot extend it. An expired request cannot later start or resume automatic reboot. Any orderly stop already begun remains pending. Timeout is retained until the board restarts; the button stays disabled so reconnecting cannot queue another reboot. Inspect USB supervisor status/logs before manual recovery. No deadline forces reset through unfinished writes.
The browser also reports unconfirmed progress after 40 seconds, including when a newly opened page receives an existing request. It never resends reboot automatically. Ordinary disconnects still require manual connection.
Status codes: 0 idle, 1 requested, 2 flushing, 3 restarting, 4 storage error, 5 timeout, 6 rejected (busy/stopped/storage failure), 7 CP/M ready. Requests during an active reboot are idempotent. Controls require the active connection and completed version-5 handshake. Update firmware and any separately hosted terminal page together.
- Output: at most four frames (2 KiB) are in flight. The browser ACKs after xterm consumes bytes, with one cumulative ACK for all frames parsed in the same task. TCP owns a frame once it accepts it; the Pico holds only the next frame, which keeps filling to 512 bytes while the window is full. An ACK frees the window and the same service pass refills it. No ACK progress for 5 minutes releases the browser.
- Batching: isolated/full frames send immediately. Later partial frames wait up to a fixed 1 ms deadline, then the next service pass. Flash pauses may delay service. A burst is flushed once; a failed flush is retried by the transport without queueing the frames again.
- Input: the browser sends one frame and waits for its ACK. ACK means queued, not executed. A frame that does not fit waits for RX space and is admitted on a later service pass; an ACK that TCP cannot take is offered again each pass. The browser caps pending input at 64 KiB, rejects an overflowing paste entirely and warns when disconnect discards input.
- Dispatch: the server calls the terminal from the lwIP receive callback on core 1. Those callbacks only record state: input is queued for the Z80 at once, and every frame is sent by the service pass that follows
cyw43_arch_poll()in the same core-1 loop. Sending from the receive path can exhaust the CYW43 driver's transmit credit while received frames are still queued in the chip, and the driver discards frames it reads while waiting for credit. Incoming messages are limited to 69 bytes; a larger one disconnects. - Memory: one 517-byte staged frame; the server reuses one frame buffer and one message buffer per connection, with no heap use per message. Four frames use 2,084 of 5,840 TCP send-buffer bytes. Core-1 stack is 8 KiB in main RAM. These are not total memory-use measurements.
- Latency: every received segment is ACKed immediately (
TF_ACK_NOW) rather than on lwIP's 250 ms delayed-ACK timer, so a browser that coalesces small writes never holds a keystroke behind an unacknowledged frame. Nagle is disabled for sends. The page uses xterm.js 6.0.0 and renders with its WebGL addon and falls back to the DOM renderer if WebGL is unavailable or its context is lost. - Cleanup: one-second close grace; callback-safe abort also runs offline. Heartbeat pings every 30 seconds with a 20-second pong timeout; the third consecutive miss without browser activity releases ownership.
Terminal diagnostics¶
Read the Ctrl-] s counters
| Fields | Meaning |
|---|---|
[web] flush_failures |
Burst flushes the transport had to retry |
[web] ack_timeouts, pong_timeouts |
Sessions released by stalled output, and missed heartbeats |
[web] last_disconnect |
Why the terminal last released the browser |
[webclose] reason, lwip_error |
The transport's reason for the last socket close, when it gave one |
[webbuf] out_frames, peak |
Output frames in flight and their high-water mark |
[webbuf] staged |
Output bytes waiting to be framed |
[consolebuf] rx, tx, peak |
Console occupancy, capacity and high-water marks |
A full window with a rising staged count means the browser or network is
slower than the Z80's output. Occupancy clears on handover; peaks survive
it. Web counters reset on reboot; console peaks reset on initialization.
Fields are individual atomic snapshots, not one transaction.
Required Integration Order¶
Preserve the Phase 9 startup sequence
- Initialize safe GPIOs and console/control queues.
- Start core 1; initialize its alarm pool, USB and flash lockout.
- With RESET held, recover the journal, load SRAM and independently verify it.
- Initialize storage/service queues before enabling workers; enable the trap and 20 MHz run clock.
Boot/core-1 failure leaves RESET asserted. Core 0 continually services flash ownership requests. Stop drains disk/service/provisioning work before holding RESET; reboot reloads CP/M. Use dedicated builds for RAM/clock qualification.
Maintained source¶
Phase 10 compiles the Phase 9 application with Z80_STAGE09_AUTOBOOT=1 and
Z80_STAGE10_NETWORK=1; it does not maintain a separate loader.
The Phase 9 source layout
separates core-0 control, core-1 workers and their coordination boundary.
- Phase 10 build configuration
- Browser page and update tool (CMake compresses changes automatically)
- WebSocket server fork (publish submodule changes to the fork before updating the parent gitlink)
Core 0 - automatic loader and I2C scheduling
#include <stdio.h>
#include "hardware/clocks.h"
#include "hardware/flash.h"
#include "hardware/watchdog.h"
#include "hardware/sync.h"
#include "pico/multicore.h"
#include "pico/stdlib.h"
#include "pico/util/queue.h"
#include "z80sbc/clock.h"
#include "z80sbc/cpu.h"
#include "z80sbc/flash_disk.h"
#include "z80sbc/flash_layout.h"
#if PICO_ON_DEVICE
#include "pico/binary_info.h"
bi_decl(bi_program_feature(Z80_FLASH_LAYOUT_FEATURE));
#endif
#include "z80sbc/io_trap.h"
#include "z80sbc/banked_pio.h"
#include "z80sbc/io_ports.h"
#include "z80sbc/sram.h"
#include "z80sbc/supervisor.h"
#include "z80sbc/terminal.h"
#include "usb_console.h"
#include "../common/io_service.h"
#include "disk_diagnostic_image.h"
#include "core_coordination.h"
#include "status_report.h"
#ifdef Z80_STAGE10_NETWORK
#include "network_runtime.h"
#include "network_setup.h"
#include "web_terminal.h"
#include "oled_display.h"
#include "speaker_io.h"
#endif
enum {
RUN_CLOCK_HZ = 20000000,
SYSTEM_CLOCK_HZ = 120000000,
DIAGNOSTIC_BYTES = Z80_FLASH_DISK_SLOT_COUNT * Z80_FLASH_DISK_SLOT_BYTES,
};
static bool boot_attempted;
static bool image_verified;
static bool running;
static bool core1_started;
static bool runtime_started;
static bool startup_failed;
static bool loading;
#ifdef Z80_STAGE10_NETWORK
static bool config_reboot_after_stop;
static bool config_reboot_ready;
static absolute_time_t config_reboot_deadline;
static bool web_reboot_active;
static absolute_time_t web_reboot_deadline;
_Alignas(8) static uint32_t core1_network_stack[8192 / sizeof(uint32_t)];
#endif
static bool stop_requested;
static bool flush_submitted;
static bool bios_announced;
static bool ccp_announced;
static bool boot_timed_out;
static bool reset_was_watchdog;
static z80_io_trap_reset_fault_t reset_fault;
static const char *boot_failure;
static uint32_t boot_markers;
static bool flash_resuming;
static absolute_time_t boot_deadline;
static volatile bool diagnostic_active;
static volatile uint32_t diagnostic_count;
static volatile uint32_t diagnostic_result;
static uint32_t diagnostic_next_progress;
static uint32_t diagnostic_fault_offset;
static uint8_t diagnostic_expected;
static uint8_t diagnostic_actual;
static void publish_cpu_state(void) {
__atomic_store_n(&cpu_state,
(running ? CPU_RUNNING : 0) |
(stop_requested ? CPU_STOPPING : 0) |
(loading ? CPU_LOADING : 0), __ATOMIC_RELEASE);
}
static uint8_t __not_in_flash_func(virtual_read)(uint8_t port, void *context) {
(void)context;
switch (port) {
case 0x00:
case 0x01:
return z80_terminal_io_read(port);
case 0x10:
case 0x11:
case 0x12:
case 0x13:
case 0x14:
return z80_flash_disk_io_read(port);
case IO_SERVICE_COMMAND_PORT:
case IO_SERVICE_REQUEST_PORT:
case IO_SERVICE_RESPONSE_PORT:
case IO_TIMER_PORT:
return z80_io_port_in(port);
#ifdef Z80_STAGE10_NETWORK
case Z80_SPEAKER_PORT:
return z80_speaker_in();
#endif
default:
return 0xFF;
}
}
static void __not_in_flash_func(diagnostic_byte)(uint8_t value) {
uint32_t offset = diagnostic_count;
if (offset < DIAGNOSTIC_BYTES) {
const uint8_t *source =
(const uint8_t *)(XIP_BASE + Z80_FLASH_DISK_OFFSET);
if (value != source[offset] && diagnostic_result == 0) {
diagnostic_fault_offset = offset;
diagnostic_expected = source[offset];
diagnostic_actual = value;
diagnostic_result = 0xE1;
}
} else {
diagnostic_result = 0xE1;
}
diagnostic_count = offset + 1;
}
static void __not_in_flash_func(boot_report)(uint8_t value) {
if (diagnostic_active) {
if (diagnostic_result == 0)
diagnostic_result = value == 0x5A &&
diagnostic_count == DIAGNOSTIC_BYTES ? 0x5A : 0xE1;
} else if (value == Z80_BOOT_REPORT_BIOS) {
__atomic_fetch_or(&boot_markers, 1u, __ATOMIC_RELEASE);
} else if (value == Z80_BOOT_REPORT_CCP) {
__atomic_fetch_or(&boot_markers, 2u, __ATOMIC_RELEASE);
}
}
static void __not_in_flash_func(virtual_write)(uint8_t port, uint8_t value,
void *context) {
(void)context;
switch (port) {
case 0x00:
z80_terminal_io_write(port, value);
break;
case Z80_BOOT_REPORT_PORT:
boot_report(value);
break;
case 0x06:
if (diagnostic_active)
diagnostic_byte(value);
break;
case 0x10:
case 0x11:
case 0x12:
case 0x13:
case 0x14:
z80_flash_disk_io_write(port, value);
break;
case IO_SERVICE_COMMAND_PORT:
case IO_SERVICE_REQUEST_PORT:
case IO_SERVICE_RESPONSE_PORT:
case IO_TIMER_PORT:
z80_io_port_out(port, value);
break;
#ifdef Z80_STAGE10_NETWORK
case Z80_SPEAKER_PORT:
z80_speaker_out(value);
break;
#endif
default:
break;
}
}
#ifdef Z80_STAGE10_NETWORK
// The shared I2C bus (OLED and speaker) is driven only from core 0, so a
// deferred speaker IN never waits on core 1's network and flash work.
static bool i2c_updates_allowed(void) {
return running && !stop_requested && !loading &&
!__atomic_load_n(&flash_pause_request, __ATOMIC_ACQUIRE);
}
static void core0_i2c_service(bool oled_updates) {
z80_speaker_service();
z80_oled_service(oled_updates);
}
static void core0_bus_wait(void) {
core0_i2c_service(false);
z80_io_trap_drain_writes();
}
#endif
static void hold_cpu(void) {
z80_io_trap_disable();
z80_cpu_fail_closed();
running = false;
image_verified = false;
stop_requested = false;
diagnostic_active = false;
}
static const char *fault_name(z80_flash_fault_point_t point) {
static const char *const names[] = {
"none", "after journal data", "after journal header",
"after target erase", "after partial target program",
"after target verification", "after journal header clear",
"safe-execute entry failure", "safe-execute exit failure",
};
return point <= Z80_FLASH_FAULT_SAFE_EXECUTE_EXIT
? names[(unsigned int)point] : "invalid";
}
static bool observe_boot(const z80_boot_status_t *status, void *context) {
(void)context;
static const char *const phases[] = {
"journal recovery", "manifest/CRC validation", "SRAM load",
"SRAM verify", "verified; RESET held LOW",
};
if (status->completed % 4096u == 0 &&
(!Z80_STAGE09_AUTOBOOT ||
(__atomic_load_n(&usb_state, __ATOMIC_ACQUIRE) & USB_CONNECTED))) {
printf("[boot] %s: %lu/%lu\n", phases[status->phase],
(unsigned long)status->completed, (unsigned long)status->total);
}
if (Z80_STAGE09_AUTOBOOT)
return true;
return !__atomic_load_n(&load_cancelled, __ATOMIC_ACQUIRE) &&
(__atomic_load_n(&usb_state, __ATOMIC_ACQUIRE) & USB_CONNECTED);
}
static void load_image(void) {
if (boot_attempted || running || runtime_started) {
printf("\n[diag] Load already attempted; stop and reboot Pico to reload.\n");
return;
}
boot_attempted = true;
loading = true;
__atomic_store_n(&load_cancelled, 0, __ATOMIC_RELEASE);
publish_cpu_state();
if (!Z80_STAGE09_AUTOBOOT)
printf("\n[boot] Load/verify only; x cancels at a 256-byte boundary.\n");
bool loaded = z80_flash_storage_init_observed(observe_boot, NULL);
loading = false;
publish_cpu_state();
if (!loaded) {
const z80_boot_status_t *status = z80_flash_boot_status();
boot_failure = status_boot_error_name(status->error);
hold_cpu();
printf("[boot] FAIL: %s phase=%u completed=%lu address=%04x "
"expected=%02x actual=%02x; RESET held LOW\n",
status_boot_error_name(status->error), (unsigned int)status->phase,
(unsigned long)status->completed, status->address,
status->expected, status->actual);
return;
}
image_verified = true;
if (!Z80_STAGE09_AUTOBOOT)
printf("[boot] PASS: 65536 bytes loaded and independently read back; "
"CPU not running. Ctrl-] g starts CP/M at %lu MHz.\n",
(unsigned long)(RUN_CLOCK_HZ / 1000000u));
}
static void start_cpm(void) {
if (!image_verified || running || runtime_started) {
printf("\n[diag] Start rejected: first load/verify with Ctrl-] l; "
"a stopped run requires Pico reboot.\n");
return;
}
__atomic_store_n(&boot_markers, 0, __ATOMIC_RELEASE);
bios_announced = ccp_announced = false;
boot_timed_out = false;
z80_io_ports_init();
#ifdef Z80_STAGE10_NETWORK
z80_banked_pio_set_read_gate(Z80_SPEAKER_PORT, z80_speaker_read_ready);
// Core 0 waiting for BUSACK must keep finishing the speaker transfer that a
// deferred IN is waiting for, and retrying that reply.
z80_cpu_set_bus_wait_hook(core0_bus_wait);
#endif
runtime_started = true;
if (!z80_io_trap_enable(virtual_read, virtual_write, NULL) ||
!z80_cpu_release_reset_and_run(RUN_CLOCK_HZ)) {
boot_failure = "trap/clock start";
hold_cpu();
printf("[boot] FAIL: trap/clock start; RESET held LOW\n");
return;
}
running = true;
__atomic_store_n(&workers_ready, 1, __ATOMIC_RELEASE);
boot_deadline = make_timeout_time_ms(10000);
if (!Z80_STAGE09_AUTOBOOT)
printf("\n[boot] CPU released from reset; configured clock=%lu Hz. "
"Waiting for BIOS evidence, not yet a CP/M PASS.\n",
(unsigned long)z80_clock_get_hz());
}
static void start_disk_diagnostic(void) {
if (!image_verified || runtime_started || running) {
printf("\n[diag] Disk test requires Ctrl-] l and no prior run.\n");
return;
}
image_verified = false;
diagnostic_count = diagnostic_result = 0;
diagnostic_fault_offset = UINT32_MAX;
diagnostic_next_progress = 65536;
if (!z80_cpu_load_and_verify(z80_disk_diagnostic,
sizeof(z80_disk_diagnostic))) {
hold_cpu();
printf("\n[diag] FAIL: disk diagnostic load/readback; RESET held LOW\n");
return;
}
diagnostic_active = true;
if (!z80_io_trap_enable(virtual_read, virtual_write, NULL) ||
!z80_cpu_release_reset_and_run(RUN_CLOCK_HZ)) {
hold_cpu();
printf("\n[diag] FAIL: disk diagnostic start; RESET held LOW\n");
return;
}
running = true;
boot_deadline = make_timeout_time_ms(300000);
printf("\n[diag] Read-only Z80 INIR/OTIR disk test at %lu MHz: "
"all 4 drives x 4096 records x 128 bytes. No disk writes. "
"CP/M SRAM image replaced; reboot afterward.\n",
(unsigned long)(RUN_CLOCK_HZ / 1000000u));
}
static core0_status_t status_state(void) {
return (core0_status_t){
.running = running,
.boot_attempted = boot_attempted,
.image_verified = image_verified,
.bios_announced = bios_announced,
.ccp_announced = ccp_announced,
.reset_was_watchdog = reset_was_watchdog,
.reset_fault = reset_fault,
.boot_failure = boot_failure,
};
}
static void print_status(bool requested) {
core0_status_t state = status_state();
status_print(&state, requested);
}
static void print_help(void) {
core0_status_t state = status_state();
status_print_help(&state);
}
static void diagnostic_command(int command) {
if (command == 'l') {
load_image();
} else if (command == 'g') {
start_cpm();
} else if (command == 't') {
start_disk_diagnostic();
} else if (command == 's') {
print_status(true);
} else if (command == 'h') {
print_help();
} else if (command == 'x') {
if (!running || diagnostic_active) {
hold_cpu();
printf("\n[diag] RESET held LOW; reboot required before a new run.\n");
print_help();
} else {
stop_requested = true;
flush_submitted = false;
__atomic_store_n(&flash_pause_request, 1, __ATOMIC_RELEASE);
printf("\n[diag] Stop requested; waiting for disk completion/flush.\n");
}
} else if (command == 'r') {
if (running || stop_requested)
printf("\n[diag] Reboot rejected: stop and flush with Ctrl-] x first.\n");
else
watchdog_reboot(0, 0, 0);
} else if (command >= '0' && command <= '8') {
if (!running) {
printf("\n[diag] Fault command rejected: CPU is not running.\n");
return;
}
z80_flash_fault_point_t point =
(z80_flash_fault_point_t)(command - '0');
z80_flash_disk_arm_fault(point);
printf("\n[diag] Armed: %s; next disk write/flush may reboot Pico.\n",
fault_name(point));
} else {
printf("\n[diag] Unknown command; Ctrl-] h for help.\n");
}
}
static void service_control(void) {
#ifdef Z80_STAGE10_NETWORK
if (z80_web_terminal_reboot_status() == Z80_WEB_REBOOT_REQUESTED) {
if (config_reboot_after_stop || config_reboot_ready || stop_requested ||
!running || diagnostic_active || z80_flash_disk_has_fatal_error()) {
z80_web_terminal_set_reboot_status(Z80_WEB_REBOOT_REJECTED);
} else if (z80_web_terminal_begin_reboot()) {
web_reboot_active = true;
web_reboot_deadline = make_timeout_time_ms(30000);
config_reboot_after_stop = true;
diagnostic_command('x');
}
}
if (web_reboot_active &&
z80_web_terminal_reboot_status() == Z80_WEB_REBOOT_TIMEOUT) {
web_reboot_active = false;
config_reboot_after_stop = config_reboot_ready = false;
printf("\n[web] Reboot cancelled after network-side timeout; "
"orderly stop remains pending.\n");
}
if (web_reboot_active && z80_flash_disk_has_fatal_error()) {
web_reboot_active = false;
config_reboot_after_stop = config_reboot_ready = false;
z80_web_terminal_set_reboot_status(Z80_WEB_REBOOT_STORAGE_ERROR);
}
if (web_reboot_active && time_reached(web_reboot_deadline)) {
web_reboot_active = false;
config_reboot_after_stop = config_reboot_ready = false;
// Keep draining/stopping; never force a reset through an unfinished write.
z80_web_terminal_set_reboot_status(Z80_WEB_REBOOT_TIMEOUT);
printf("\n[web] Reboot timed out; automatic restart cancelled. "
"Orderly stop remains pending.\n");
}
if (config_reboot_ready && time_reached(config_reboot_deadline)) {
config_reboot_ready = false;
watchdog_reboot(0, 0, 0);
return;
}
#endif
/* Peek without the queue lock, which would mask the PIO1 IRQ on every pass.
* Core 0 is the only consumer, so a racing add is seen on the next pass. */
uint16_t command;
if (queue_get_level_unsafe(&control_commands) != 0 &&
queue_try_remove(&control_commands, &command)) {
#ifdef Z80_STAGE10_NETWORK
if (command == USB_CONFIG_REBOOT) {
if (!running || diagnostic_active || z80_flash_disk_has_fatal_error()) {
printf("\n[config] FAIL: automatic reboot rejected: CP/M is not "
"running normally or storage has failed. CPU state unchanged; "
"inspect Ctrl-] s before manual recovery.\n");
} else {
config_reboot_after_stop = true;
if (!stop_requested)
diagnostic_command('x');
}
} else if ((config_reboot_after_stop || config_reboot_ready) &&
command != 's' && command != 'h' &&
command != USB_REPORT_CONNECTION) {
printf("\n[config] Orderly reboot in progress; command rejected.\n");
} else
#endif
if (command == USB_REPORT_CONNECTION) {
if (!Z80_STAGE09_AUTOBOOT) {
print_help();
print_status(false);
} else if (!running && !loading && boot_attempted) {
printf("\n[boot] %s; RESET held LOW. Ctrl-] s for status, "
"Ctrl-] r to reboot.\n",
boot_failure != NULL ? "FAIL: automatic CP/M boot stopped"
: "CP/M stopped");
print_status(false);
}
} else if (startup_failed && command != 'r' && command != 's' &&
command != 'h') {
printf("\n[diag] Rejected: core 1 initialization failed; CPU held reset.\n");
} else {
diagnostic_command(command);
}
}
publish_cpu_state();
}
static void service_run(void) {
if (!running)
return;
if (diagnostic_active) {
uint32_t interrupts = save_and_disable_interrupts();
uint32_t count = diagnostic_count;
uint32_t result = diagnostic_result;
restore_interrupts(interrupts);
if (result != 0 || time_reached(boot_deadline)) {
hold_cpu();
printf("\n[diag] %s: disk bytes checked=%lu/%u "
"fault_offset=%08lx expected=%02x actual=%02x; "
"RESET held LOW; Ctrl-] r then load CP/M again.\n",
result == 0x5A ? "PASS" : "FAIL",
(unsigned long)count, DIAGNOSTIC_BYTES,
(unsigned long)diagnostic_fault_offset,
diagnostic_expected, diagnostic_actual);
} else if (count >= diagnostic_next_progress) {
printf("[diag] disk bytes checked=%lu/%u\n",
(unsigned long)count, DIAGNOSTIC_BYTES);
diagnostic_next_progress += 65536;
}
return;
}
uint32_t markers = __atomic_load_n(&boot_markers, __ATOMIC_ACQUIRE);
if ((markers & 1u) && !bios_announced) {
bios_announced = true;
if (!Z80_STAGE09_AUTOBOOT)
printf("\n[boot] Observed BIOS cold-entry report from Z80.\n");
}
if ((markers & 2u) && !ccp_announced) {
ccp_announced = true;
if (!Z80_STAGE09_AUTOBOOT)
printf("\n[boot] Observed BIOS handoff to CCP; "
"confirm A>, DIR and transient/warm-boot tests manually.\n");
}
if (!boot_timed_out && (!bios_announced || !ccp_announced) &&
time_reached(boot_deadline)) {
boot_failure = "BIOS/CCP evidence timeout";
printf("\n[boot] FAIL: BIOS/CCP evidence timeout; stopping safely. "
"Rebuild/provision the matching boot package if markers are absent.\n");
status_print_pio();
stop_requested = true;
boot_timed_out = true;
__atomic_store_n(&flash_pause_request, 1, __ATOMIC_RELEASE);
}
if (z80_flash_disk_has_fatal_error()) {
#ifdef Z80_STAGE10_NETWORK
if (config_reboot_after_stop) {
config_reboot_after_stop = false;
printf("\n[config] FAIL: storage error; automatic reboot cancelled.\n");
}
if (web_reboot_active) {
web_reboot_active = false;
z80_web_terminal_set_reboot_status(Z80_WEB_REBOOT_STORAGE_ERROR);
}
#endif
boot_failure = "fatal storage error";
hold_cpu();
printf("\n[diag] FAIL: fatal storage error; RESET held LOW. "
"Reboot to recover; preserve USB log.\n");
return;
}
#ifdef Z80_STAGE10_NETWORK
if (bios_announced && ccp_announced && !stop_requested &&
z80_web_terminal_reboot_status() == Z80_WEB_REBOOT_IDLE)
z80_web_terminal_set_reboot_status(Z80_WEB_REBOOT_CPM_READY);
#endif
if (!stop_requested)
return;
if (flash_resuming) {
if (!__atomic_load_n(&flash_paused, __ATOMIC_ACQUIRE)) {
flash_resuming = false;
flush_submitted = false;
__atomic_store_n(&flash_pause_request, 1, __ATOMIC_RELEASE);
}
return;
}
if (__atomic_load_n(&flash_paused, __ATOMIC_ACQUIRE)) {
// PIO completes OUTs ahead of core 0. Park the Z80 between machine cycles,
// then apply every completed OUT before deciding, so the disk state seen
// here is exactly what the Z80 has issued.
if (!z80_cpu_request_bus(500000)) {
boot_failure = "stop bus request";
hold_cpu();
printf("\n[diag] FAIL: Z80 did not release the bus for stop; "
"RESET held LOW.\n");
return;
}
uint32_t interrupts = save_and_disable_interrupts();
z80_io_trap_drain_writes();
bool quiescent = z80_flash_disk_quiescent();
#ifdef Z80_STAGE10_NETWORK
quiescent = quiescent && z80_speaker_quiescent();
#endif
if (quiescent)
hold_cpu();
restore_interrupts(interrupts);
if (!quiescent && !z80_cpu_release_bus(500000)) {
boot_failure = "stop bus release";
hold_cpu();
printf("\n[diag] FAIL: Z80 bus release after stop check; "
"RESET held LOW.\n");
return;
}
if (quiescent) {
#ifdef Z80_STAGE10_NETWORK
if (config_reboot_after_stop) {
config_reboot_after_stop = false;
config_reboot_ready = true;
config_reboot_deadline = make_timeout_time_ms(1000);
if (web_reboot_active)
z80_web_terminal_set_reboot_status(Z80_WEB_REBOOT_RESTARTING);
printf("\n[config] Disks flushed; RESET held LOW, clock stopped. "
"Rebooting in one second.\n");
} else
#endif
{
printf("\n[diag] Disk quiescent; RESET held LOW, clock stopped. "
"Ctrl-] r reboots/reloads the supervisor.\n");
print_help();
}
} else {
flash_resuming = true;
__atomic_store_n(&flash_pause_request, 0, __ATOMIC_RELEASE);
}
} else if (!flush_submitted) {
z80_flash_disk_request_flush();
flush_submitted = true;
}
}
int core0_main(void) {
z80_safe_startup();
reset_was_watchdog = watchdog_caused_reboot();
reset_fault = z80_io_trap_take_reset_fault();
set_sys_clock_pll(1200000000, 5, 2);
z80_terminal_queue_init();
queue_init(&control_commands, sizeof(uint16_t), 4);
z80_usb_console_prepare();
#ifdef Z80_STAGE10_NETWORK
z80_network_setup_init();
#endif
bool clock_ready = clock_get_hz(clk_sys) == SYSTEM_CLOCK_HZ;
#ifdef Z80_STAGE10_NETWORK
multicore_launch_core1_with_stack(core1_main, core1_network_stack,
sizeof(core1_network_stack));
#else
multicore_launch_core1(core1_main);
#endif
core1_started = true;
absolute_time_t ready_deadline = make_timeout_time_ms(1000);
while (!__atomic_load_n(&core1_ready, __ATOMIC_ACQUIRE) &&
!time_reached(ready_deadline))
tight_loop_contents();
if (!clock_ready ||
__atomic_load_n(&core1_ready, __ATOMIC_ACQUIRE) != 1) {
z80_cpu_fail_closed();
startup_failed = true;
boot_failure = !clock_ready ? "expected 120 MHz supervisor clock"
: "core 1 USB/flash-safe initialization";
while (true) {
printf("FAIL: %s; CPU held reset\n", boot_failure);
service_control();
busy_wait_us_32(1000000);
tight_loop_contents();
}
}
if (Z80_STAGE09_AUTOBOOT) {
load_image();
if (image_verified)
start_cpm();
}
publish_cpu_state();
while (true) {
if (__atomic_load_n(&workers_ready, __ATOMIC_ACQUIRE)) {
#ifdef Z80_STAGE10_NETWORK
// Before the trap poll, so a finished transfer releases its IN this pass.
core0_i2c_service(i2c_updates_allowed());
#endif
z80_flash_core0_service();
}
if (core1_started && z80_io_ports_reboot_requested()) {
hold_cpu();
watchdog_reboot(0, 0, 0);
while (true)
tight_loop_contents();
}
service_control();
service_run();
publish_cpu_state();
tight_loop_contents();
}
}
Core 1 - USB, storage and network scheduling
#include <stdio.h>
#include "pico/flash.h"
#include "pico/stdio_usb.h"
#include "pico/stdlib.h"
#include "tusb.h"
#include "z80sbc/flash_disk.h"
#include "z80sbc/io_ports.h"
#include "z80sbc/terminal.h"
#include "ansi_input.h"
#include "usb_console.h"
#include "core_coordination.h"
#ifdef Z80_STAGE10_NETWORK
#include "network_runtime.h"
#include "network_setup.h"
#endif
static bool command_pending;
static bool usb_was_connected;
static uint32_t previous_cpu_state;
static bool previous_cr;
static uint32_t reported_rx_drops;
static uint32_t reported_tx_drops;
#ifdef Z80_STAGE10_NETWORK
static bool browser_was_connected;
static bool config_reboot_queue_full;
#endif
/* USB owns the CP/M terminal unless the setup shell or a browser has it. */
static bool usb_owns_terminal(void) {
#ifdef Z80_STAGE10_NETWORK
return !z80_network_setup_active() && !z80_terminal_browser_connected();
#else
return true;
#endif
}
static void forward_terminal_byte(uint8_t value) {
if (value == 0)
return;
if (value == '\n' && previous_cr) {
previous_cr = false;
return;
}
previous_cr = value == '\r';
#ifdef Z80_STAGE10_NETWORK
z80_terminal_usb_receive(value);
#else
z80_terminal_receive(&value, 1);
#endif
}
static void receive_usb_byte(uint8_t value) {
uint32_t state = __atomic_load_n(&cpu_state, __ATOMIC_ACQUIRE);
if (command_pending) {
command_pending = false;
if (value != USB_COMMAND_ESCAPE) {
#ifdef Z80_STAGE10_NETWORK
if (value == 'c') {
if ((state & (CPU_RUNNING | CPU_STOPPING)) == CPU_RUNNING)
z80_network_setup_request();
else
printf("\n[config] Rejected: CP/M must be running, not stopping.\n");
return;
}
#endif
if (value == 'x' && (state & CPU_LOADING) && !Z80_STAGE09_AUTOBOOT)
__atomic_store_n(&load_cancelled, 1, __ATOMIC_RELEASE);
else {
uint16_t command = value;
if (!queue_try_add(&control_commands, &command))
printf("\n[diag] FAIL: supervisor command queue full; retry command.\n");
}
return;
}
} else if (value == USB_COMMAND_ESCAPE) {
ansi_input_reset();
command_pending = true;
return;
}
if (state & CPU_LOADING) {
if (value == 'x' && !Z80_STAGE09_AUTOBOOT)
__atomic_store_n(&load_cancelled, 1, __ATOMIC_RELEASE);
return;
}
#ifdef Z80_STAGE10_NETWORK
if ((state & (CPU_RUNNING | CPU_STOPPING)) == CPU_RUNNING &&
z80_network_setup_input(value))
return;
if (z80_terminal_browser_connected())
return;
#endif
if (!(state & CPU_RUNNING)) {
if (Z80_STAGE09_AUTOBOOT)
printf("\n[diag] CPU held; Ctrl-] s for status, "
"Ctrl-] r to retry automatic boot.\n");
else
printf("\n[diag] CPU held; use Ctrl-] l then Ctrl-] g.\n");
return;
}
forward_terminal_byte(
ansi_input_process(value, to_ms_since_boot(get_absolute_time())));
}
static void service_usb(void) {
bool connected = stdio_usb_connected();
uint32_t state = __atomic_load_n(&cpu_state, __ATOMIC_ACQUIRE);
if (state != previous_cpu_state) {
ansi_input_reset();
previous_cr = false;
previous_cpu_state = state;
}
#ifdef Z80_STAGE10_NETWORK
if (connected != usb_was_connected)
z80_terminal_usb_connected(connected);
bool browser_connected = z80_terminal_browser_connected();
if (browser_connected != browser_was_connected) {
ansi_input_reset();
previous_cr = false;
browser_was_connected = browser_connected;
}
z80_network_setup_usb_service(
connected, (state & (CPU_RUNNING | CPU_STOPPING)) == CPU_RUNNING);
if (z80_network_setup_reboot_requested()) {
uint16_t command = USB_CONFIG_REBOOT;
if (queue_try_add(&control_commands, &command)) {
z80_network_setup_reboot_queued();
config_reboot_queue_full = false;
} else if (!config_reboot_queue_full) {
printf("\n[config] Supervisor queue full; retrying orderly reboot.\n");
config_reboot_queue_full = true;
}
}
#else
if (connected != usb_was_connected)
z80_terminal_set_connected(connected);
#endif
__atomic_store_n(&usb_state, (connected ? USB_CONNECTED : 0)
#ifdef Z80_STAGE10_NETWORK
| (browser_connected ? USB_BROWSER : 0)
| (z80_network_setup_active() ? USB_SETUP : 0)
#endif
, __ATOMIC_RELEASE);
if (connected != usb_was_connected)
ansi_input_reset();
if (connected && !usb_was_connected) {
command_pending = false;
previous_cr = false;
uint16_t command = USB_REPORT_CONNECTION;
if (!queue_try_add(&control_commands, &command))
printf("\n[diag] FAIL: connection report queue full; Ctrl-] s for status.\n");
}
usb_was_connected = connected;
if (!connected)
return;
uint8_t input[32];
size_t received = z80_usb_console_read(input, sizeof(input));
for (size_t index = 0; index < received; ++index)
receive_usb_byte(input[index]);
/* Only a lone Escape waits on the clock; other passes skip the time read. */
if (ansi_input_escape_pending() && (state & CPU_RUNNING) &&
!command_pending && usb_owns_terminal())
forward_terminal_byte(
ansi_input_process(0, to_ms_since_boot(get_absolute_time())));
if (z80_terminal_output_pending() && usb_owns_terminal()) {
uint8_t data[64];
size_t capacity = tud_cdc_write_available();
if (capacity > sizeof(data))
capacity = sizeof(data);
size_t count = z80_terminal_peek(data, capacity);
if (count) {
size_t accepted = z80_usb_console_write(data, count);
z80_terminal_supply(data, accepted);
}
}
uint32_t rx = z80_terminal_rx_drop_count();
uint32_t tx = z80_terminal_tx_drop_count();
if (rx != reported_rx_drops || tx != reported_tx_drops) {
printf("\n[diag] FAIL: terminal overflow rx=%lu tx=%lu; "
"reduce paste/output rate and repeat acceptance.\n",
(unsigned long)rx, (unsigned long)tx);
reported_rx_drops = rx;
reported_tx_drops = tx;
}
}
static void run_workers(void) {
z80_flash_core1_service();
z80_io_ports_core1_service();
#ifdef Z80_STAGE10_NETWORK
z80_network_setup_core1_service();
#endif
}
static bool workers_quiescent(void) {
return z80_flash_disk_quiescent() && z80_io_ports_quiescent()
#ifdef Z80_STAGE10_NETWORK
&& z80_network_setup_quiescent()
#endif
;
}
/* Disk and service workers run first, then USB, then the network. A pause
* request lets the workers drain, then holds them until core 0 withdraws it. */
static void core1_service(void) {
bool active = __atomic_load_n(&workers_ready, __ATOMIC_ACQUIRE);
bool pausing =
active && __atomic_load_n(&flash_pause_request, __ATOMIC_ACQUIRE);
if (pausing) {
if (!__atomic_load_n(&flash_paused, __ATOMIC_ACQUIRE)) {
run_workers();
if (workers_quiescent())
__atomic_store_n(&flash_paused, 1, __ATOMIC_RELEASE);
}
} else if (active) {
__atomic_store_n(&flash_paused, 0, __ATOMIC_RELEASE);
run_workers();
}
z80_usb_console_service();
service_usb();
#ifdef Z80_STAGE10_NETWORK
if (pausing)
z80_network_terminal_core1_service();
else if (active)
z80_network_core1_service();
#endif
}
void core1_main(void) {
bool flash_ready = flash_safe_execute_core_init();
bool usb_ready = z80_usb_console_init();
if (!flash_ready || !usb_ready)
printf("\n[diag] FAIL: core 1 USB/flash-safe initialization.\n");
__atomic_store_n(&core1_ready, flash_ready && usb_ready ? 1u : 2u,
__ATOMIC_RELEASE);
while (true) {
if (usb_ready)
core1_service();
tight_loop_contents();
}
}
Shared terminal queues and transport handover
#include "z80sbc/terminal.h"
#include <string.h>
#include "pico/critical_section.h"
#include "pico/stdlib.h"
enum {
TERM_DATA_PORT = 0x00,
TERM_STATUS_PORT = 0x01,
TERM_RX_DEPTH = 128,
TERM_TX_DEPTH = 512,
TERM_STATUS_RX_READY = 1u << 0,
TERM_STATUS_TX_ROOM = 1u << 1,
TERM_STATUS_CLIENT = 1u << 7,
};
typedef struct {
uint8_t *data;
size_t capacity;
size_t head;
size_t count;
size_t peak;
} terminal_ring_t;
static uint8_t rx_data[TERM_RX_DEPTH];
static uint8_t tx_data[TERM_TX_DEPTH];
static terminal_ring_t rx_queue = {rx_data, TERM_RX_DEPTH, 0, 0, 0};
static terminal_ring_t tx_queue = {tx_data, TERM_TX_DEPTH, 0, 0, 0};
static critical_section_t terminal_lock;
static uint32_t client_connected;
static uint32_t browser_connected;
/* Let core 1 skip empty output polls without taking the core 0 I/O lock. */
static uint32_t tx_pending;
static bool usb_connected;
static uint32_t rx_drops;
static uint32_t tx_drops;
/* Counts change only under terminal_lock, but are stored atomically so the
* core-0 status poll can read them without the cross-core lock. */
static inline void ring_set_count(terminal_ring_t *ring, size_t count) {
__atomic_store_n(&ring->count, count, __ATOMIC_RELAXED);
}
static inline size_t ring_count(const terminal_ring_t *ring) {
return __atomic_load_n(&ring->count, __ATOMIC_RELAXED);
}
static void clear_queues(void) {
rx_queue.head = tx_queue.head = 0;
ring_set_count(&rx_queue, 0);
ring_set_count(&tx_queue, 0);
__atomic_store_n(&tx_pending, 0, __ATOMIC_RELEASE);
}
static void __not_in_flash_func(ring_add)(terminal_ring_t *ring, uint8_t value) {
size_t count = ring->count;
ring->data[(ring->head + count) & (ring->capacity - 1)] = value;
ring_set_count(ring, ++count);
if (count > ring->peak)
ring->peak = count;
}
static void __not_in_flash_func(ring_drop_oldest)(terminal_ring_t *ring) {
ring->head = (ring->head + 1) & (ring->capacity - 1);
ring_set_count(ring, ring->count - 1);
}
static size_t ring_peek(const terminal_ring_t *ring, uint8_t *data,
size_t capacity) {
size_t count = capacity < ring->count ? capacity : ring->count;
size_t first = ring->capacity - ring->head;
if (first > count)
first = count;
memcpy(data, ring->data + ring->head, first);
memcpy(data + first, ring->data, count - first);
return count;
}
static size_t ring_remove(terminal_ring_t *ring, uint8_t *data,
size_t capacity) {
size_t count = ring_peek(ring, data, capacity);
ring->head = (ring->head + count) & (ring->capacity - 1);
ring_set_count(ring, ring->count - count);
return count;
}
void z80_terminal_queue_init(void) {
if (!critical_section_is_initialized(&terminal_lock))
critical_section_init(&terminal_lock);
clear_queues();
rx_queue.peak = tx_queue.peak = 0;
__atomic_store_n(&client_connected, 0, __ATOMIC_RELEASE);
__atomic_store_n(&browser_connected, 0, __ATOMIC_RELEASE);
usb_connected = false;
__atomic_store_n(&rx_drops, 0, __ATOMIC_RELAXED);
__atomic_store_n(&tx_drops, 0, __ATOMIC_RELAXED);
}
uint8_t __not_in_flash_func(z80_terminal_io_read)(uint8_t port) {
if (port == TERM_STATUS_PORT) {
/* Lock-free: core 0 is the only RX consumer and TX producer, so a racing
* core 1 update can only make this poll conservative for one cycle. */
bool connected = z80_terminal_client_connected();
uint8_t value = 0;
if (ring_count(&rx_queue))
value |= TERM_STATUS_RX_READY;
if (!connected || ring_count(&tx_queue) < TERM_TX_DEPTH)
value |= TERM_STATUS_TX_ROOM;
if (connected)
value |= TERM_STATUS_CLIENT;
return value;
}
if (!ring_count(&rx_queue))
return 0;
critical_section_enter_blocking(&terminal_lock);
uint8_t value = 0;
if (rx_queue.count) {
value = rx_queue.data[rx_queue.head];
ring_drop_oldest(&rx_queue);
}
critical_section_exit(&terminal_lock);
return value;
}
void __not_in_flash_func(z80_terminal_io_write)(uint8_t port, uint8_t value) {
if (port != TERM_DATA_PORT)
return;
critical_section_enter_blocking(&terminal_lock);
if (z80_terminal_client_connected()) {
if (tx_queue.count < TERM_TX_DEPTH) {
bool was_empty = tx_queue.count == 0;
ring_add(&tx_queue, value);
if (was_empty)
__atomic_store_n(&tx_pending, 1, __ATOMIC_RELEASE);
} else
__atomic_fetch_add(&tx_drops, 1, __ATOMIC_RELAXED);
}
critical_section_exit(&terminal_lock);
}
void z80_terminal_receive(const uint8_t *data, size_t length) {
if (data == NULL)
return;
for (size_t index = 0; index < length; ++index) {
critical_section_enter_blocking(&terminal_lock);
if (rx_queue.count == TERM_RX_DEPTH) {
ring_drop_oldest(&rx_queue);
__atomic_fetch_add(&rx_drops, 1, __ATOMIC_RELAXED);
}
ring_add(&rx_queue, data[index] == '\n' ? '\r' : data[index]);
critical_section_exit(&terminal_lock);
}
}
size_t z80_terminal_peek(uint8_t *data, size_t capacity) {
if (data == NULL || capacity == 0 ||
!__atomic_load_n(&tx_pending, __ATOMIC_ACQUIRE))
return 0;
critical_section_enter_blocking(&terminal_lock);
size_t count = ring_peek(&tx_queue, data, capacity);
critical_section_exit(&terminal_lock);
return count;
}
size_t z80_terminal_supply(uint8_t *data, size_t capacity) {
if (data == NULL || capacity == 0 ||
!__atomic_load_n(&tx_pending, __ATOMIC_ACQUIRE))
return 0;
critical_section_enter_blocking(&terminal_lock);
size_t count = ring_remove(&tx_queue, data, capacity);
if (tx_queue.count == 0)
__atomic_store_n(&tx_pending, 0, __ATOMIC_RELEASE);
critical_section_exit(&terminal_lock);
return count;
}
bool z80_terminal_output_pending(void) {
return __atomic_load_n(&tx_pending, __ATOMIC_ACQUIRE) != 0;
}
bool z80_terminal_browser_try_receive(const uint8_t *data, size_t length) {
if (data == NULL || length == 0 || length > TERM_RX_DEPTH)
return false;
critical_section_enter_blocking(&terminal_lock);
bool accepted = z80_terminal_browser_connected() &&
TERM_RX_DEPTH - rx_queue.count >= length;
if (accepted)
for (size_t i = 0; i < length; ++i)
ring_add(&rx_queue, data[i] == '\n' ? '\r' : data[i]);
critical_section_exit(&terminal_lock);
return accepted;
}
void z80_terminal_browser_connect(void) {
critical_section_enter_blocking(&terminal_lock);
clear_queues();
__atomic_store_n(&browser_connected, 1, __ATOMIC_RELEASE);
__atomic_store_n(&client_connected, 1, __ATOMIC_RELEASE);
critical_section_exit(&terminal_lock);
}
void z80_terminal_set_connected(bool connected) {
critical_section_enter_blocking(&terminal_lock);
__atomic_store_n(&client_connected, connected, __ATOMIC_RELEASE);
if (!connected) {
tx_queue.head = 0;
ring_set_count(&tx_queue, 0);
__atomic_store_n(&tx_pending, 0, __ATOMIC_RELEASE);
}
critical_section_exit(&terminal_lock);
}
void z80_terminal_browser_disconnect(void) {
critical_section_enter_blocking(&terminal_lock);
if (z80_terminal_browser_connected()) {
__atomic_store_n(&browser_connected, 0, __ATOMIC_RELEASE);
__atomic_store_n(&client_connected, usb_connected, __ATOMIC_RELEASE);
clear_queues();
}
critical_section_exit(&terminal_lock);
}
bool z80_terminal_browser_connected(void) {
return __atomic_load_n(&browser_connected, __ATOMIC_ACQUIRE) != 0;
}
void z80_terminal_usb_connected(bool connected) {
critical_section_enter_blocking(&terminal_lock);
if (!z80_terminal_browser_connected()) {
__atomic_store_n(&client_connected, connected, __ATOMIC_RELEASE);
if (usb_connected != connected)
clear_queues();
}
usb_connected = connected;
critical_section_exit(&terminal_lock);
}
void z80_terminal_usb_receive(uint8_t value) {
critical_section_enter_blocking(&terminal_lock);
if (usb_connected && !z80_terminal_browser_connected()) {
if (rx_queue.count == TERM_RX_DEPTH) {
ring_drop_oldest(&rx_queue);
__atomic_fetch_add(&rx_drops, 1, __ATOMIC_RELAXED);
}
ring_add(&rx_queue, value == '\n' ? '\r' : value);
}
critical_section_exit(&terminal_lock);
}
size_t z80_terminal_usb_supply(uint8_t *data, size_t capacity) {
if (data == NULL || capacity == 0 ||
!__atomic_load_n(&tx_pending, __ATOMIC_ACQUIRE))
return 0;
critical_section_enter_blocking(&terminal_lock);
size_t count = 0;
if (usb_connected && !z80_terminal_browser_connected()) {
count = ring_remove(&tx_queue, data, capacity);
if (tx_queue.count == 0)
__atomic_store_n(&tx_pending, 0, __ATOMIC_RELEASE);
}
critical_section_exit(&terminal_lock);
return count;
}
uint32_t z80_terminal_rx_drop_count(void) {
return __atomic_load_n(&rx_drops, __ATOMIC_RELAXED);
}
uint32_t z80_terminal_tx_drop_count(void) {
return __atomic_load_n(&tx_drops, __ATOMIC_RELAXED);
}
z80_terminal_buffer_stats_t z80_terminal_get_buffer_stats(void) {
critical_section_enter_blocking(&terminal_lock);
z80_terminal_buffer_stats_t stats = {
(uint32_t)rx_queue.count, TERM_RX_DEPTH, (uint32_t)rx_queue.peak,
(uint32_t)tx_queue.count, TERM_TX_DEPTH, (uint32_t)tx_queue.peak,
};
critical_section_exit(&terminal_lock);
return stats;
}
bool __not_in_flash_func(z80_terminal_client_connected)(void) {
return __atomic_load_n(&client_connected, __ATOMIC_ACQUIRE) != 0;
}
Core-1 network lifecycle
#include "network_runtime.h"
#include <cstdio>
#include <cstring>
#include "pico/cyw43_arch.h"
#include "pico/stdlib.h"
#include "lwip/apps/sntp.h"
#include "lwip/netif.h"
#include "network_setup.h"
#include "z80sbc/io_ports.h"
#include "../common/io_service.h"
namespace {
constexpr uint32_t RETRY_DELAY_MS = 5000;
constexpr uint32_t ASSOCIATION_TIMEOUT_MS = 30000;
enum class WifiState { uninitialized, associating, online, association_backoff };
WifiState wifi_state = WifiState::uninitialized;
absolute_time_t retry_deadline = nil_time;
absolute_time_t association_deadline = nil_time;
absolute_time_t address_deadline = nil_time;
bool time_client_started;
z80_network_config_t active_config = {};
uint32_t network_generation;
char active_hostname[32];
// Binary IPv4 behind the published address text; 0 means none is published.
uint32_t published_ipv4;
void clear_address() {
++network_generation;
z80_io_ports_set_network_address("");
published_ipv4 = 0;
address_deadline = nil_time;
}
void start_association() {
// lwIP copies this into the DHCP client options when it requests a lease.
// Set it before starting each association so the router sees the same name
// that the Z80 system information reports.
if (z80_service_info(0, active_hostname, sizeof(active_hostname)) == 0)
return;
cyw43_arch_lwip_begin();
if (netif_default != nullptr)
netif_set_hostname(netif_default, active_hostname);
cyw43_arch_lwip_end();
int result = cyw43_arch_wifi_connect_async(
active_config.ssid, active_config.password,
active_config.password[0] ? CYW43_AUTH_WPA2_AES_PSK : CYW43_AUTH_OPEN);
if (result == PICO_OK) {
association_deadline = make_timeout_time_ms(ASSOCIATION_TIMEOUT_MS);
wifi_state = WifiState::associating;
} else {
retry_deadline = make_timeout_time_ms(RETRY_DELAY_MS);
wifi_state = WifiState::association_backoff;
printf("[network] FAIL: association start=%d; retry in 5 seconds.\n", result);
}
}
void refresh_network_address() {
if (!time_reached(address_deadline))
return;
address_deadline = make_timeout_time_ms(1000);
// Compare the binary address; format text only when it changes.
uint32_t ipv4 = 0;
char address[16] = {};
bool formatted = true;
cyw43_arch_lwip_begin();
if (netif_default != nullptr) {
ipv4 = ip4_addr_get_u32(netif_ip4_addr(netif_default));
if (ipv4 != published_ipv4 && ipv4 != 0)
formatted = ip4addr_ntoa_r(netif_ip4_addr(netif_default), address,
sizeof(address)) != nullptr;
}
cyw43_arch_lwip_end();
if (ipv4 == published_ipv4)
return;
if (!formatted) {
printf("[network] FAIL: IPv4 address formatting.\n");
ipv4 = 0;
if (published_ipv4 == 0)
return;
}
published_ipv4 = ipv4;
z80_io_ports_set_network_address(ipv4 != 0 ? address : "");
}
} // namespace
extern "C" uint32_t z80_network_generation(void) {
return network_generation;
}
extern "C" bool z80_network_poll(void) {
if (!z80_wireless_init())
return false;
cyw43_arch_poll();
const z80_network_config_t *config = z80_network_config();
if (config == nullptr) {
if (wifi_state != WifiState::uninitialized) {
clear_address();
cyw43_wifi_leave(&cyw43_state, CYW43_ITF_STA);
wifi_state = WifiState::uninitialized;
active_config = {};
}
return false;
}
if (strcmp(active_config.ssid, config->ssid) != 0 ||
strcmp(active_config.password, config->password) != 0) {
clear_address();
if (wifi_state != WifiState::uninitialized)
cyw43_wifi_leave(&cyw43_state, CYW43_ITF_STA);
active_config = *config;
wifi_state = WifiState::uninitialized;
}
if (wifi_state == WifiState::uninitialized) {
cyw43_arch_enable_sta_mode();
start_association();
return false;
}
if (wifi_state == WifiState::association_backoff) {
if (time_reached(retry_deadline))
start_association();
return false;
}
cyw43_arch_lwip_begin();
int status = cyw43_tcpip_link_status(&cyw43_state, CYW43_ITF_STA);
cyw43_arch_lwip_end();
if (wifi_state == WifiState::associating) {
if (status == CYW43_LINK_UP) {
int result = cyw43_wifi_pm(&cyw43_state, CYW43_NO_POWERSAVE_MODE);
if (result != PICO_OK) {
cyw43_wifi_leave(&cyw43_state, CYW43_ITF_STA);
retry_deadline = make_timeout_time_ms(RETRY_DELAY_MS);
wifi_state = WifiState::association_backoff;
printf("[network] FAIL: disabling Wi-Fi power saving=%d; retry in 5 seconds.\n",
result);
return false;
}
cyw43_arch_lwip_begin();
if (!time_client_started) {
sntp_setoperatingmode(SNTP_OPMODE_POLL);
sntp_setservername(0, "pool.ntp.org");
sntp_init();
time_client_started = true;
}
cyw43_arch_lwip_end();
wifi_state = WifiState::online;
} else if (status < CYW43_LINK_DOWN || time_reached(association_deadline)) {
cyw43_wifi_leave(&cyw43_state, CYW43_ITF_STA);
retry_deadline = make_timeout_time_ms(RETRY_DELAY_MS);
wifi_state = WifiState::association_backoff;
printf("[network] FAIL: association status=%d or timeout; retry in 5 seconds.\n",
status);
return false;
} else {
return false;
}
}
if (status != CYW43_LINK_UP) {
clear_address();
cyw43_wifi_leave(&cyw43_state, CYW43_ITF_STA);
retry_deadline = make_timeout_time_ms(RETRY_DELAY_MS);
wifi_state = WifiState::association_backoff;
printf("[network] Wi-Fi link lost; retry in 5 seconds.\n");
return false;
}
refresh_network_address();
return true;
}
Web terminal session and flow control
#include <atomic>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <new>
#include "pico/cyw43_arch.h"
#include "pico/stdlib.h"
#include "pico_ws_server/web_socket_server.h"
#include "web_terminal.h"
#include "z80sbc/terminal.h"
// Core 1 owns this module. The WebSocket callbacks run inside
// cyw43_arch_poll() or z80_web_terminal_poll(), never in an interrupt. Only
// the reboot status and the published statistics are shared with core 0.
//
// Callbacks only record state; service_session() does all the sending, after
// cyw43_arch_poll() has drained the radio. A frame sent from the receive path
// can run the CYW43 driver out of transmit credit while received frames are
// still queued in the chip, and the driver discards the frames it reads while
// it waits for credit. The browser then stalls until TCP retransmits them.
namespace {
constexpr uint16_t SERVER_PORT = Z80_WEB_TERMINAL_PORT;
constexpr uint32_t SERVER_CONNECTIONS = 4;
constexpr uint64_t SERVER_RETRY_US = 5u * 1000000u;
constexpr size_t FRAME_BYTES = Z80_WEB_TERMINAL_FRAME_BYTES;
constexpr size_t INPUT_BYTES = 64;
constexpr size_t HEADER_BYTES = 5;
constexpr uint32_t OUTPUT_WINDOW = Z80_WEB_TERMINAL_OUTPUT_WINDOW;
constexpr uint64_t OUTPUT_COALESCE_US = 1000;
constexpr uint32_t PROTOCOL_VERSION = 5;
constexpr uint64_t REBOOT_DEADLINE_US = 35u * 1000000u;
constexpr uint64_t HEARTBEAT_US = 30u * 1000000u;
constexpr uint64_t PONG_TIMEOUT_US = 20u * 1000000u;
constexpr uint64_t PING_RETRY_US = 250u * 1000u;
// Tolerate two misses; disconnect on the third consecutive miss.
constexpr uint8_t MAX_MISSED_PONGS = 2;
// xterm ACKs output only after its write callback runs; allow a backgrounded
// or busy browser to drain the output window before restoring USB.
constexpr uint64_t OUTPUT_ACK_TIMEOUT_US = 5u * 60u * 1000000ull;
enum : uint8_t {
OUTPUT = 1, INPUT = 2, OUTPUT_ACK = 3, INPUT_ACK = 4,
HELLO = 5, READY = 6, REBOOT = 7, REBOOT_STATUS = 8,
};
WebSocketServer *server;
bool server_started;
uint64_t server_retry_deadline;
int32_t active_connection = -1;
// A session this module ended; its transport close reason is still to come.
int32_t closing_connection = -1;
uint32_t session_network_generation;
bool network_online;
bool protocol_ready;
bool hello_pending;
// TCP owns each frame once it accepts it, so only the next frame is held.
uint8_t staged[HEADER_BYTES + FRAME_BYTES];
size_t staged_length;
// Sequences start at 1; output_acked is the last one the browser consumed.
uint32_t output_sequence = 1;
uint32_t output_acked;
uint64_t output_batch_deadline;
uint64_t output_ack_deadline;
uint8_t pending_input[INPUT_BYTES];
size_t pending_input_length;
uint32_t input_sequence = 1;
bool input_ack_pending;
uint64_t ping_deadline;
uint64_t pong_deadline;
uint32_t ping_sequence;
uint8_t ping_payload[4];
bool awaiting_pong;
bool activity_since_ping;
uint8_t missed_pongs;
std::atomic<uint32_t> reboot_status{Z80_WEB_REBOOT_IDLE};
uint32_t reported_reboot_status = UINT32_MAX;
// Core 1 owns this deadline. Keep it across browser disconnects so core 0
// cannot leave a request looking active forever if its main loop stalls.
uint64_t reboot_deadline;
bool reboot_deadline_active;
z80_web_terminal_stats_t terminal_stats;
template <typename T> void publish(T &field, T value) {
__atomic_store_n(&field, value, __ATOMIC_RELAXED);
}
template <typename T> T published(const T &field) {
return __atomic_load_n(&field, __ATOMIC_RELAXED);
}
void count(uint32_t &counter) { publish(counter, published(counter) + 1); }
uint32_t frames_in_flight() { return output_sequence - output_acked - 1; }
void publish_output_usage() {
uint32_t frames = frames_in_flight();
publish(terminal_stats.window_frames, frames);
publish(terminal_stats.staged_bytes, (uint32_t)staged_length);
if (frames > published(terminal_stats.window_frames_peak))
publish(terminal_stats.window_frames_peak, frames);
}
void publish_close(uint32_t disconnect_reason, WebSocketServer::CloseReason reason,
int transport_error) {
if (disconnect_reason != Z80_WEB_TERMINAL_DISCONNECT_NONE)
publish(terminal_stats.last_disconnect_reason, disconnect_reason);
publish(terminal_stats.last_ws_close_reason, (uint32_t)reason);
publish(terminal_stats.last_ws_transport_error, (int32_t)transport_error);
}
uint32_t read_sequence(const uint8_t *data) {
return (uint32_t)data[0] | (uint32_t)data[1] << 8 |
(uint32_t)data[2] << 16 | (uint32_t)data[3] << 24;
}
void write_sequence(uint8_t *data, uint32_t value) {
for (unsigned i = 0; i < 4; ++i)
data[i] = (uint8_t)(value >> (8 * i));
}
void reset_session() {
protocol_ready = hello_pending = input_ack_pending = false;
reported_reboot_status = UINT32_MAX;
input_sequence = output_sequence = 1;
output_acked = 0;
staged_length = pending_input_length = 0;
output_batch_deadline = 0;
awaiting_pong = activity_since_ping = false;
missed_pongs = 0;
publish_output_usage();
}
void disconnect_client(z80_web_terminal_disconnect_reason_t reason) {
if (active_connection < 0)
return;
closing_connection = active_connection;
active_connection = -1;
// Do not pair this disconnect with an earlier session's close details.
publish_close(reason, WebSocketServer::CloseReason::unknown, 0);
if (!server->abort((uint32_t)closing_connection))
printf("[network] Terminal connection already removed during teardown.\n");
z80_terminal_browser_disconnect();
reset_session();
}
void protocol_error(const char *problem) {
printf("[network] FAIL: %s; reload the terminal page.\n", problem);
disconnect_client(Z80_WEB_TERMINAL_DISCONNECT_PROTOCOL_ERROR);
}
bool send_control(uint8_t type, uint32_t sequence) {
uint8_t frame[HEADER_BYTES] = {type};
write_sequence(frame + 1, sequence);
return server->sendMessage((uint32_t)active_connection, frame, sizeof(frame));
}
void check_reboot_deadline() {
if (!reboot_deadline_active)
return;
uint32_t status = reboot_status.load(std::memory_order_acquire);
while (status == Z80_WEB_REBOOT_REQUESTED || status == Z80_WEB_REBOOT_FLUSHING ||
status == Z80_WEB_REBOOT_RESTARTING) {
if (time_us_64() < reboot_deadline)
return;
if (reboot_status.compare_exchange_weak(status, Z80_WEB_REBOOT_TIMEOUT,
std::memory_order_acq_rel)) {
printf("[web] Reboot timed out waiting for supervisor progress. "
"No forced reset; inspect USB supervisor status.\n");
break;
}
}
reboot_deadline_active = false;
}
void request_reboot() {
uint32_t status = reboot_status.load(std::memory_order_acquire);
// Repeated commands cannot restart an active operation. Failure can be
// retried, but only core 0 decides whether it is safe to proceed.
while (status != Z80_WEB_REBOOT_REQUESTED &&
status != Z80_WEB_REBOOT_FLUSHING &&
status != Z80_WEB_REBOOT_RESTARTING &&
status != Z80_WEB_REBOOT_TIMEOUT) {
if (reboot_status.compare_exchange_weak(status, Z80_WEB_REBOOT_REQUESTED,
std::memory_order_acq_rel)) {
reboot_deadline = time_us_64() + REBOOT_DEADLINE_US;
reboot_deadline_active = true;
break;
}
}
reported_reboot_status = UINT32_MAX;
}
void report_reboot_status() {
uint32_t status = reboot_status.load(std::memory_order_acquire);
if (protocol_ready && status != reported_reboot_status &&
send_control(REBOOT_STATUS, status)) {
reported_reboot_status = status;
// A watchdog reset sends no TCP FIN. Close explicitly while the network is
// still alive so the browser can reconnect without waiting for TCP timeout.
if (status == Z80_WEB_REBOOT_RESTARTING &&
!server->close((uint32_t)active_connection))
printf("[network] Close frame not sent; connection scheduled for cleanup.\n");
}
}
// ACK means queued for the Z80. A full RX queue holds the one frame the
// browser may send until there is room; nothing is evicted to make space.
void receive_input(const uint8_t *data, size_t length) {
if (!z80_terminal_browser_try_receive(data, length)) {
if (data != pending_input)
memcpy(pending_input, data, length);
pending_input_length = length;
return;
}
pending_input_length = 0;
++input_sequence;
input_ack_pending = true;
}
void pump_output(uint64_t now) {
bool sent = false;
bool changed = false;
for (;;) {
size_t supplied = z80_terminal_supply(staged + HEADER_BYTES + staged_length,
FRAME_BYTES - staged_length);
staged_length += supplied;
changed |= supplied != 0;
if (!staged_length || frames_in_flight() == OUTPUT_WINDOW)
break;
// Send a full frame now; let a partial one gather bytes for a moment.
if (staged_length < FRAME_BYTES && now < output_batch_deadline)
break;
staged[0] = OUTPUT;
write_sequence(staged + 1, output_sequence);
auto result = server->enqueueMessage((uint32_t)active_connection, staged,
HEADER_BYTES + staged_length);
if (result == WebSocketServer::SendResult::would_block)
break;
if (result != WebSocketServer::SendResult::queued) {
printf("[network] FAIL: terminal output enqueue (%u); restoring USB.\n",
(unsigned)result);
disconnect_client(Z80_WEB_TERMINAL_DISCONNECT_OUTPUT_SEND_ERROR);
return;
}
output_ack_deadline = now + OUTPUT_ACK_TIMEOUT_US;
++output_sequence;
staged_length = 0;
output_batch_deadline = now + OUTPUT_COALESCE_US;
sent = changed = true;
}
if (changed)
publish_output_usage();
if (sent && !server->flush((uint32_t)active_connection))
count(terminal_stats.flush_failures);
}
void acknowledge_output(uint32_t sequence) {
uint32_t consumed = sequence - output_acked;
if (!consumed || consumed > frames_in_flight()) {
protocol_error("unexpected terminal output acknowledgement");
return;
}
output_acked = sequence;
output_ack_deadline = time_us_64() + OUTPUT_ACK_TIMEOUT_US;
publish_output_usage();
}
void note_browser_activity() {
missed_pongs = 0;
if (awaiting_pong)
activity_since_ping = true;
}
void on_connect(WebSocketServer &websocket, uint32_t connection) {
if (active_connection >= 0) {
// Last connection wins. The abort reclaims the old slot without waiting
// for a close reply.
closing_connection = active_connection;
if (!websocket.abort((uint32_t)closing_connection))
printf("[network] Previous terminal connection already removed.\n");
printf("[network] New browser replaced the previous terminal connection.\n");
}
active_connection = (int32_t)connection;
reset_session();
z80_terminal_browser_connect();
hello_pending = true;
}
void on_close(WebSocketServer &, uint32_t connection,
WebSocketServer::CloseReason reason, int transport_error) {
if ((int32_t)connection == closing_connection) {
closing_connection = -1;
publish_close(Z80_WEB_TERMINAL_DISCONNECT_NONE, reason, transport_error);
return;
}
if ((int32_t)connection != active_connection)
return;
active_connection = -1;
publish_close(Z80_WEB_TERMINAL_DISCONNECT_SOCKET_CLOSED, reason, transport_error);
printf("[network] Terminal connection closed: %s",
z80_web_terminal_ws_close_name((uint32_t)reason));
if (transport_error)
printf(" (lwIP error=%d)", transport_error);
printf("; restoring USB.\n");
z80_terminal_browser_disconnect();
reset_session();
}
void on_message(WebSocketServer &, uint32_t connection,
const void *data, size_t length) {
if ((int32_t)connection != active_connection)
return;
const uint8_t *frame = static_cast<const uint8_t *>(data);
if (length < HEADER_BYTES) {
protocol_error("invalid terminal protocol");
return;
}
note_browser_activity();
const uint8_t type = frame[0];
const uint32_t sequence = read_sequence(frame + 1);
const size_t payload = length - HEADER_BYTES;
if (type == READY && !payload && sequence == PROTOCOL_VERSION) {
if (!protocol_ready) {
protocol_ready = true;
ping_deadline = time_us_64() + HEARTBEAT_US;
}
} else if (!protocol_ready) {
protocol_error("terminal frame before the handshake");
} else if (type == REBOOT && !payload && sequence == 0) {
request_reboot();
} else if (type == OUTPUT_ACK && !payload) {
acknowledge_output(sequence);
} else if (type == INPUT && payload && payload <= INPUT_BYTES) {
// The browser sends one frame at a time and waits for its ACK.
if (sequence != input_sequence || pending_input_length || input_ack_pending)
protocol_error("out-of-order terminal input");
else
receive_input(frame + HEADER_BYTES, payload);
} else {
protocol_error("invalid terminal frame");
}
}
void on_pong(WebSocketServer &, uint32_t connection,
const void *data, size_t length) {
if ((int32_t)connection != active_connection)
return;
if (awaiting_pong && length == sizeof(ping_payload) &&
memcmp(data, ping_payload, length) == 0) {
awaiting_pong = false;
missed_pongs = 0;
activity_since_ping = false;
} else {
// Even a late PONG proves the peer is responsive and can satisfy a
// newer ping's liveness check through note_browser_activity().
note_browser_activity();
}
}
bool ensure_server() {
if (server_started)
return true;
uint64_t now = time_us_64();
if (now < server_retry_deadline)
return false;
if (server == nullptr) {
server = new (std::nothrow) WebSocketServer(SERVER_CONNECTIONS);
if (server == nullptr) {
printf("[network] FAIL: server allocation; retry in 5 seconds.\n");
server_retry_deadline = now + SERVER_RETRY_US;
return false;
}
server->setConnectCallback(on_connect);
server->setCloseCallback(on_close);
server->setMessageCallback(on_message);
server->setPongCallback(on_pong);
server->setTcpNoDelay(true);
if (!server->setMaxMessageSize(HEADER_BYTES + INPUT_BYTES)) {
printf("[network] FAIL: terminal message limit; retry in 5 seconds.\n");
delete server;
server = nullptr;
server_retry_deadline = now + SERVER_RETRY_US;
return false;
}
}
server_started = server->startListening(SERVER_PORT);
if (!server_started) {
printf("[network] FAIL: HTTP/WebSocket listen; retry in 5 seconds.\n");
server_retry_deadline = now + SERVER_RETRY_US;
}
return server_started;
}
// False when the browser missed too many heartbeats and was disconnected.
bool service_heartbeat(uint64_t now) {
if (awaiting_pong) {
if (now < pong_deadline)
return true;
awaiting_pong = false;
if (activity_since_ping) {
missed_pongs = 0;
} else {
count(terminal_stats.pong_timeouts);
if (++missed_pongs > MAX_MISSED_PONGS) {
printf("[network] Terminal client missed too many PONGs; restoring USB.\n");
disconnect_client(Z80_WEB_TERMINAL_DISCONNECT_PONG_TIMEOUT);
return false;
}
}
activity_since_ping = false;
}
if (now >= ping_deadline) {
write_sequence(ping_payload, ++ping_sequence);
if (server->sendPing((uint32_t)active_connection, ping_payload,
sizeof(ping_payload))) {
awaiting_pong = true;
activity_since_ping = false;
ping_deadline = now + HEARTBEAT_US;
pong_deadline = now + PONG_TIMEOUT_US;
} else {
ping_deadline = now + PING_RETRY_US;
}
}
return true;
}
void service_session() {
const uint64_t now = time_us_64();
// A control stays pending until TCP takes it.
if (hello_pending)
hello_pending = !send_control(HELLO, PROTOCOL_VERSION);
if (pending_input_length)
receive_input(pending_input, pending_input_length);
if (input_ack_pending)
input_ack_pending = !send_control(INPUT_ACK, input_sequence - 1);
report_reboot_status();
if (frames_in_flight() && now >= output_ack_deadline) {
count(terminal_stats.ack_timeouts);
printf("[network] Terminal client timed out; restoring USB.\n");
disconnect_client(Z80_WEB_TERMINAL_DISCONNECT_OUTPUT_ACK_TIMEOUT);
return;
}
if (protocol_ready && service_heartbeat(now))
pump_output(now);
}
} // namespace
extern "C" z80_web_terminal_stats_t z80_web_terminal_get_stats(void) {
z80_web_terminal_stats_t stats;
stats.flush_failures = published(terminal_stats.flush_failures);
stats.ack_timeouts = published(terminal_stats.ack_timeouts);
stats.pong_timeouts = published(terminal_stats.pong_timeouts);
stats.window_frames = published(terminal_stats.window_frames);
stats.window_frames_peak = published(terminal_stats.window_frames_peak);
stats.staged_bytes = published(terminal_stats.staged_bytes);
stats.last_disconnect_reason = published(terminal_stats.last_disconnect_reason);
stats.last_ws_close_reason = published(terminal_stats.last_ws_close_reason);
stats.last_ws_transport_error = published(terminal_stats.last_ws_transport_error);
return stats;
}
extern "C" const char *z80_web_terminal_disconnect_name(uint32_t reason) {
static const char *const names[] = {
"none", "socket-closed", "network-lost", "output-ack-timeout",
"pong-timeout", "protocol-error", "output-send-error",
};
return reason < sizeof(names) / sizeof(names[0]) ? names[reason] : "none";
}
extern "C" const char *z80_web_terminal_ws_close_name(uint32_t reason) {
using Reason = WebSocketServer::CloseReason;
static const char *const names[] = {
"unknown", "peer-tcp-closed", "peer-ws-close", "invalid-ws-input",
"pong-backpressure", "tcp-write-error", "tcp-output-error", "tcp-error",
"close-timeout", "server-abort", "local-close",
};
static_assert((unsigned)Reason::unknown == 0 &&
(unsigned)Reason::peer_tcp_closed == 1 &&
(unsigned)Reason::peer_websocket_close == 2 &&
(unsigned)Reason::invalid_websocket_input == 3 &&
(unsigned)Reason::pong_backpressure == 4 &&
(unsigned)Reason::tcp_write_error == 5 &&
(unsigned)Reason::tcp_output_error == 6 &&
(unsigned)Reason::tcp_error == 7 &&
(unsigned)Reason::close_timeout == 8 &&
(unsigned)Reason::server_abort == 9 &&
(unsigned)Reason::local_close == 10 &&
sizeof(names) / sizeof(names[0]) == 11);
return reason < sizeof(names) / sizeof(names[0]) ? names[reason] : "unknown";
}
extern "C" void z80_web_terminal_poll(bool online, uint32_t generation) {
check_reboot_deadline();
if (!online && !network_online && generation == session_network_generation)
return;
if (!online && server == nullptr) {
session_network_generation = generation;
return;
}
cyw43_arch_lwip_begin();
if (!online || generation != session_network_generation) {
disconnect_client(Z80_WEB_TERMINAL_DISCONNECT_NETWORK_LOST);
session_network_generation = generation;
}
network_online = online;
if (online)
ensure_server();
// Also runs offline, to reclaim the connection just disconnected.
if (server != nullptr)
server->poll();
if (active_connection >= 0)
service_session();
cyw43_arch_lwip_end();
}
extern "C" z80_web_reboot_status_t z80_web_terminal_reboot_status(void) {
return (z80_web_reboot_status_t)reboot_status.load(std::memory_order_acquire);
}
extern "C" bool z80_web_terminal_begin_reboot(void) {
uint32_t expected = Z80_WEB_REBOOT_REQUESTED;
return reboot_status.compare_exchange_strong(expected, Z80_WEB_REBOOT_FLUSHING,
std::memory_order_acq_rel);
}
extern "C" void z80_web_terminal_set_reboot_status(z80_web_reboot_status_t status) {
if (status == Z80_WEB_REBOOT_CPM_READY) {
uint32_t expected = Z80_WEB_REBOOT_IDLE;
reboot_status.compare_exchange_strong(expected, status, std::memory_order_acq_rel);
} else {
uint32_t previous = reboot_status.load(std::memory_order_acquire);
// Timeout is terminal for this boot. A late supervisor must not overwrite
// it with progress, or allow a browser retry to resurrect a cancelled stop.
while (previous != Z80_WEB_REBOOT_TIMEOUT &&
!reboot_status.compare_exchange_weak(previous, status,
std::memory_order_acq_rel)) {}
}
}