A framed, bidirectional host ↔ target console for the TOWER Core Module
(STM32L083CZ). One serial link carries everything: structured logs, raw
print! output, self-describing events, and a RouterOS-style shell with
target-authoritative TAB completion and a declarative, EEPROM-backed settings
framework. The host side is the tower CLI/TUI.
Status: complete and hardware-verified on the Radio Dongle (STM32L08x). The wire codec (COBS + CRC-32 + postcard), the DMA-backed
Uarttransport (RX on a circular DMA buffer) with a low-power-correctWakeGuard, the synchronous panic path, log / print / event streaming with overflow accounting, chunked shell responses, the settings framework (6 kinds, range/enum validation, value completion), the app-extensible deep-merge command tree, and thetowerCLI + TUI are all implemented and tested on real hardware. The codec has 20 host tests including 9000 fuzz iterations (run in the tower-protocol repo).
This is the standalone reference for using and maintaining the console subsystem — architecture, wire protocol, the firmware + host APIs, and a worked example per feature.
| Piece | Where | What it is |
|---|---|---|
| Firmware SDK | this repo: src/console.rs, src/shell.rs |
the on-MCU console: logging backend, event/shell APIs, the framed UART transport |
tower host CLI |
github.com/hardwario/tower-cli (binary tower) |
decodes the framed link on your machine: logs / events / shell / TUI |
tower-protocol |
github.com/hardwario/tower-protocol (shared, no_std) |
the single source of truth for the wire format, used by both ends |
Because tower-protocol is shared, the wire format cannot drift between firmware
and host. Both ends pin it to the same git tag (currently v1.3.0).
The console owns USART1 on the Core Module:
| Signal | Pin | Notes |
|---|---|---|
| TX | PA9 | target → host |
| RX | PA10 | host → target (full-duplex; wired through the dongle's USB bridge) |
| Baud | — | 115200 8N1 |
It is DMA-backed: TX on DMA1_CH4, RX on a circular DMA buffer (RingBufferedUartRx)
on DMA1_CH5 — a different DMA channel group (DMA1_CHANNEL4_5_6_7) from the WS2812 LED
strip's DMA1_CH3 (DMA1_CHANNEL2_3), so the strip and the console both use DMA with no
channel or IRQ overlap. The hardware-filled RX ring means a host → target byte is never lost
to receiver overrun while a TX burst or critical section delays the reader.
The link is always framed (binary COBS frames). A plain serial terminal (screen,
minicom, or any raw monitor) shows gibberish — use the tower CLI (tower logs),
which decodes the frames.
# Firmware: flash any example (it auto-starts the console).
TOWER_DEVICE=/dev/cu.usbserial-140 just flash example console_demo
# Host: build the CLI once, then stream the device.
cd ../tower-cli && cargo build --release # produces `tower`
tower logs # auto-detects a single USB serial port
tower -d /dev/cu.usbserial-140 logs # or name the device explicitlyEvery app gets the console for free: Board::take (via the app! macro) starts it
and emits a boot Hello + banner naming the app. You don't wire anything up.
#![no_std]
#![no_main]
use tower::{app, board::Board};
use log::info;
async fn run(_b: Board) {
info!("hello from my app"); // → framed Log on USART1, rendered by `tower logs`
}
app!(run); producers one writer task host
───────── ─────────────── ────
log::{error..trace}! ┐
print! / println! ├─ try_send ─► TX_CHANNEL ─► seq+encode ─► UartTx (DMA ch4) ═══► tower
console::event().await┘ (drop- (depth 4) (COBS+CRC+ (PA9, 115200)
shell responses newest) postcard)
▲
host commands ═► RingBufferedUartRx ──► FrameDecoder ─► shell ─► responses ─┘
(PA10, DMA ch5 ring)
- Producers build an owned message and
try_sendit into a bounded channel (depth 4). Non-blocking and safe from any context (including thelogbackend). If the queue is full the newest is dropped and a counter bumped. - One writer loop (inside
console::manager) owns the UART TX. It assigns the per-frameseqat send time (so a gap on the wire means real loss, never a queue drop), encodes the frame, and writes it. Before the next real frame it emits aDropped{count}marker if anything was dropped. - The codec (
tower-protocol) frames every message identically — see Wire protocol. - RX is read by the same manager and routed by frame type — shell frames to the shell's channel. The manager owns the whole UART so it can be torn down/rebuilt on USB unplug/plug — see Low power & the dynamic console.
On the STM32L0 an enabled USART holds embassy's STOP refcount, so a permanently-on
console would keep the low-power executor out of STOP forever — an unplugged / battery
node would burn ~3.5 mA (WFI at 16 MHz) instead of idling at µA. The console is therefore
dynamic: console::manager (spawned by Board::take) owns USART1 + PA9/PA10 + the
VBUS_SENSE (PA12) EXTI, and gates the whole UART on USB presence:
- USB present → builds the DMA-backed
Uart(RX ring on ch5), runs the writer + the RX frame-router, and re-emits aHello. While USB is present the enabled USART keeps the MCU in WFI (not STOP) — which is what we want: the console/shell stay responsive and the node is powered/plugged anyway. - USB absent → drops the UART, which disables USART1 and releases the STOP refcount, so the executor reaches STOP and the node idles at µA (~32 µA @3 V measured, vs 3527 µA with a permanently-on console). It then waits for USB on the PA12 EXTI edge plus a ~500 ms RTC poll: EXTI works in STOP and brings the console up the instant VBUS rises — no reset — while the poll is a fallback for a missed edge. PA12 is driven by the FT231X's CBUS3 output (a push-pull ~3.3 V logic level, not a 5 V divider), which asserts only tens of ms after power-up, i.e. after the executor may have already armed the edge wait — so relying on the edge alone can hang; the poll closes that gap. The periodic wake costs sub-µA (STOP floor unchanged).
RX is owned by the manager and routed by frame type: ShellCommand/ShellComplete
go to the shell (shell::dispatch_frame; the shell registers its command tree + settings
via shell::serve/serve_ext, which just store them — no task). So the shell keeps
working across teardown/rebuild without owning the raw RX half.
The WakeGuard: even while the console is up, STOP would gate the USART clock and the
writer's awaited TXE interrupt would never fire. The writer holds a WakeGuard(Stop1)
per transmit burst (STOP → plain WFI so the USART stays clocked and the interrupt
fires), dropped between bursts.
The dynamic behavior (console live on plug, torn down on unplug, no reset) can only be verified with real USB. Recommended bench procedure:
Setup. Power the board from a separate supply (battery / power-profiler), not
from USB, so plugging/unplugging USB only toggles VBUS_SENSE — it doesn't cut power.
Have the tower CLI on PATH and flash a console-exercising firmware (console_full is
ideal): just flash example console_full. (Flashing is unaffected — the STM32 bootloader
runs before the app's console.)
| Test | Steps | Pass |
|---|---|---|
| A — console up on USB | USB plugged, tower logs |
Hello then streaming logs; tower console shell responds (/system/resource print, TAB) |
| B — live plug/unplug | tower logs running + current meter on VDD; unplug USB, then re-plug |
On unplug: logs stop, current drops to µA (~50 ms). On re-plug: reconnects, fresh Hello, logs resume |
| B (no-reset check) | across the unplug/replug in B, watch the log uptime timestamps | Uptime is continuous / increasing (device kept running+sleeping). If it resets to 0 → the device rebooted, i.e. not the dynamic path |
| C — headless boot → plug | power board with no USB (idles at µA), then plug USB | PA12 EXTI (or the ~500 ms poll fallback) wakes the MCU; console appears in tower logs within ~½ s |
If a test fails — where to look:
- Console never appears on plug → PA12 isn't reading logic-high when USB is plugged. It's driven by the FT231X CBUS3 push-pull output (see the caveat below), which the ~500 ms poll already tolerates being late — so if it never appears, scope PA12 on plug: it must reach the STM32's V_IH, or the manager never sees "USB present." (Hardware issue — e.g. CBUS3 mis-configured in the FTDI EEPROM — not the firmware logic.)
- Logs appear but garbled → framing / the reused static
TX_BUF/RX_BUFacross rebuilds. - Shell silent, logs fine → the RX router (
dispatch_frame) orSHELL_PARAMSnot set (ano_shellapp has no shell by design). - Current doesn't drop on unplug → the UART isn't being dropped; check the manager's
unplug branch (
select3) fires (VBUS debounce is 50 ms). - Host reports a
seqgap on re-plug → should not occur: the writer'sseqresets per USB session andtowerresets its per-linkseqtracking when it decodes the freshHello. (Older CLI builds printed a benign one-line gap warning here and kept going.)
The executor is dead in a panic, so the channel/writer can't run. The panic handler
silences the buffered ISR and blocking-writes one framed Log (level Error)
straight to the USART registers via the PAC, leading with a 0x00 so any byte still
in the shift register can't prefix and corrupt the frame. tower logs shows the panic
message + location like any other error.
It then resets (it does not halt). Before resetting it writes the crash text into a
reset-surviving .uninit RAM breadcrumb (crashlog, zero EEPROM wear); the next boot
re-reports it as a crash-module ERROR frame and via /system/crash print — so a
battery node that faults with USB unplugged still surfaces its crash, after it has
already recovered. HardFaults follow the same path (with the faulting PC/LR). Crash
loops are bounded by the bootguard's EEPROM-write backoff, and the run length shows in
/system/eeprom print.
Use the standard log macros. The host adds the timestamp,
color, and columns — the device only sends level + uptime + module + message.
use log::{error, warn, info, debug, trace};
error!("sensor {} fault: {:?}", id, e);
info!("interval set to {} s", interval);- Levels: Error, Warn, Info, Debug, Trace. Default max level is Trace; lower
it at runtime with
log::set_max_level(...). moduleis the last::segment of the log target (e.g.power).- Over-long lines are truncated, never dropped — a message past
MAX_MSG(192 bytes) is clipped at a char boundary (a plainheaplesswrite would reject it wholesale and log nothing).
print! / println! (from the tower crate) send a Print message — verbatim text
with no level/timestamp, rendered inline by tower logs. println! appends \r\n.
use tower::println;
println!("raw line {}", n);If producers outrun the writer, the newest messages are dropped and the host shows a single marker before the next frame:
⚠ 22 log frame(s) dropped (device queue full)
No sequence gap is reported, because dropped messages never consumed a seq.
Structured, self-describing key=value records — the host renders any app's events
with no shared per-app schema. event() is async: while USB is present it applies
backpressure (awaits a free queue slot, so a burst is never dropped); while USB is
unplugged there is no writer to drain the queue, so it falls back to drop-newest with a
count (surfaced later as the Dropped marker) rather than parking the low-power executor
forever on a full queue. Call it from async code.
use tower::console;
console::event("measurement", &[("temp_c", "2351"), ("rh", "41")]).await;Caps (clipped if exceeded): name ≤ 24 bytes, ≤ 6 fields, each key ≤ 12 / value ≤
20 bytes — sized so a worst-case event always fits one frame. View with tower events:
12:01:07.245 EVENT measurement temp_c=2351 rh=41
A RouterOS-style command shell over the same link, with target-authoritative TAB completion: the firmware owns parsing, the command tree, execution and completion, so the host can never suggest something the device won't accept. Opt in by serving it with the board's EEPROM storage:
use tower::{app, board::Board, shell};
async fn run(b: Board) {
shell::serve(b.spawner, b.kv); // base tree + settings
// … your app; logs/events keep flowing on the same link …
}
app!(run);Drive it from the host interactively or one-shot:
tower shell # interactive REPL; TAB completes
tower exec "/system/resource print" # run one command, print result, exit (scripts/CI)
tower complete "/system settings set " # ask the target what completes here| Command | Does |
|---|---|
/system reboot |
flush the reply, then SCB::sys_reset() |
/system/resource print |
firmware/protocol version, uptime, CPU, clock, memory |
/system/stack print |
measured stack high-water (boot-painted) — used/free/total vs the budget |
/system/eeprom print |
wear gauge: compaction flips vs budget, live/free bytes, resets |
/system/eeprom wipe confirm |
factory reset — zero the whole store, then reboot |
/system/crash print |
the last panic/HardFault recovered at this boot (from the breadcrumb) |
/system settings print |
list every setting and its value |
/system settings set <name>=<value> |
validate by kind + persist |
/system settings get <name> |
show value + constraints + default |
/export |
dump all settings as settings set lines (reproducible config) |
Unknown commands return result code 1. Tokens split on /, space, and tab, so
/system settings set and /system/settings/set are equivalent.
Responses longer than one frame are chunked (192-byte chunk/last frames) and
reassembled by the host into one response — /system/resource print is a 7-line
example.
Settings are declarative: an app provides a &'static [Setting] table and the
shell derives print / set / get / export and completion from it — no
per-setting code. Each Setting.key is a u8 local within the shell's EEPROM
namespace (NS_SHELL) — the shell prefixes it, so app keys can't collide with other
subsystems. 0x00..=0x0F is reserved for the SDK base table (identity = 0x00,
addr = 0x01, the rest headroom for base growth); app settings start at 0x10.
The partition is load-bearing: a key collision silently aliases two settings' storage.
use tower::shell::{Setting, Kind};
static SETTINGS: &[Setting] = &[
Setting { key: 0x10, name: "interval", kind: Kind::Uint { min: 1, max: 3600 }, default: "30" },
Setting { key: 0x11, name: "verbose", kind: Kind::Bool, default: "false" },
Setting { key: 0x12, name: "mode", kind: Kind::Enum(&["p2p","star","mesh"]),default: "star" },
Setting { key: 0x13, name: "tx_power", kind: Kind::Int { min: -30, max: 20 }, default: "14" },
];Kind |
Accepts | Stored as | Use for |
|---|---|---|---|
Str { max } |
1..=max bytes of UTF-8 (max ≤ 64) |
raw bytes | names, SSIDs, tokens |
Uint { min, max } |
decimal u32 in range |
4 LE bytes | intervals, ports, counts, thresholds |
Int { min, max } |
decimal i32 in range |
4 LE bytes | offsets, tx-power dBm, calibration |
Bool |
true/false, on/off, 1/0 |
1 byte | flags |
Enum(&[&str]) |
one of the listed values | raw bytes | modes, regions, roles |
Addr |
32-bit hex (0x1a2b3c4d), or auto / random |
4 LE bytes | radio addresses (0 = auto sentinel) |
- Ranges/choices are enforced on
set; an invalid value returns result 2 and prints the constraint:> /system settings set interval=99999 invalid value for interval (uint 1..=3600) getshows the derived metadata:> /system settings get tx_power tx_power = -10 [int -30..=20, default 14]- Completion is value-aware: completing after
=offers theEnumchoices orBooltrue/false:> /system settings set mode=⇥ → p2p star mesh - Unset / unreadable / out-of-range stored values fall back to the
default.
identity is just an SDK Str setting (key 0x00, ≤32 chars) — nothing special.
addr (key 0x01, Kind::Addr, default auto) is the SDK's other base setting: the
unit's 32-bit radio address (the frame-header src/dest, i.e. the net-layer addr). auto
resolves to the chip-UID-derived address; random mints a fresh non-zero one from the
STM32L0 hardware TRNG (board::rand_u32). get shows the effective address, so it never
lies about what the radio uses. It is boot-applied — reboot to re-address a live node.
serve_ext lets an app add its own commands and settings. App commands
deep-merge with the SDK tree at every level, so you can drop a command into an
existing menu or grow your own nested subtree. App settings join the same
/system settings table.
use core::fmt::Write;
use tower::shell::{self, Args, Ctx, Entry, Kind, Outcome, Setting};
// A handler writes its response via `write!(ctx, …)` and returns an Outcome.
fn cmd_status(ctx: &mut Ctx, _args: &[&str]) -> Outcome {
let _ = write!(ctx, "radio: idle, last RSSI -71 dBm");
Outcome::ok() // or Outcome::code(n) for a non-zero result
}
static APP_COMMANDS: &[Entry] = &[
Entry::cmd("uptime", Args::None, cmd_status), // → /uptime (top level)
Entry::menu("system", &[Entry::cmd("hello", Args::None, cmd_status)]), // → /system hello (into SDK menu)
Entry::menu("radio", &[ // → /radio … (new subtree)
Entry::cmd("status", Args::None, cmd_status),
Entry::menu("test", &[Entry::cmd("ping", Args::None, cmd_status)]), // → /radio test ping
]),
];
static APP_SETTINGS: &[Setting] = &[
Setting { key: 0x10, name: "interval", kind: Kind::Uint { min: 1, max: 3600 }, default: "30" },
];
async fn run(b: Board) {
shell::serve_ext(b.spawner, b.kv, APP_COMMANDS, APP_SETTINGS);
}The handler context (Ctx) exposes:
write!(ctx, …)—Ctximplementscore::fmt::Write(the response text);ctx.kv— the shell-namespaced (NS_SHELL) EEPROM handle; settings are keyed byu8within it, so use keys other than the SDK base's0x00(identity);ctx.settings— the merged settings table (iter()/find(name)).
Merge rules: a menu shadows a same-named command (menus stay descendable); on a
command-name collision the SDK command wins; completion dedups names. Apps don't need
any host changes — tower is target-authoritative.
tower [-d <device>] <command>
The device auto-detects when exactly one USB serial device is present; otherwise pass
-d/--device. Subcommands:
| Command | What |
|---|---|
devices |
list all serial ports (one bare port name per line) |
logs [--no-colors] [--send <text>] |
stream logs + print! + the Dropped marker; --send pokes the device's RX once on connect |
events [--no-colors] |
stream structured events |
shell |
interactive REPL; TAB completes (via rustyline); exit/quit to leave |
exec "<line>" |
run one command, print the (reassembled) response, exit non-zero on a device error/timeout |
complete "<line>" |
print what the target would complete at the cursor (handy for testing/scripts) |
console |
the full-screen TUI (below) |
monitor [--hex] |
transport debugging: dump decoded frames, or every raw byte with --hex |
logs/events auto-reconnect on unplug. Frame-level integrity is reported: a corrupt
frame and a seq gap each print a one-line warning.
A three-pane ratatui terminal app — Device Events, the SSH-style
Interactive Shell (scrollback + a > prompt with in-pane TAB hints), Device Logs — all
on one serial drain (35/65 split):
HARDWARIO TOWER Console v0.1.0 — /dev/cu.usbserial-140 ●
┌Device Events──────────────┐┌Device Logs────────────────────────────────┐
│12:01 measurement temp=23.5 ││12:01 [ 64.030] INFO app: heartbeat 32 │
│ ││12:01 [ 66.031] WARN app: link flaky │
┌Interactive Shell──────────┐│ │
│> /system settings print ││ │
│identity = node-7 ││ │
│> /system/eeprom print ││ │
│flips: 3 / 100000 (0.0%) ││ │
│> _ ││ │
└────────────────────────────┘└────────────────────────────────────────────┘
<Shift-Tab> Focus <F3> Zoom <F5> Pause <F8> Clear <Shift-F8> Clear All …
Only the clock [uptime] LEVEL prefix of a log line is severity-tinted; command syntax is
highlighted (paths / commands / key=value), and F5 pauses the view only — frames keep
being captured while the viewport holds still.
| Key | Action |
|---|---|
| type + Enter | send a shell command (history with ↑/↓) |
| Tab | target-authoritative completion (names + enum/bool values) |
| Shift-Tab | move focus between panes |
| F3 | zoom the focused pane full-screen |
| F5 | pause the streaming panes (shell stays live) |
| F8 | clear the focused pane |
| PageUp/Down, ↑/↓ | scroll a focused stream pane |
| F10 | quit (restores the terminal; a panic restores it too) |
wire: COBS( inner ) 0x00
inner: ver_type(1) │ seq(2, LE) │ payload(postcard) │ crc32(4, LE)
ver_type = (PROTOCOL_VERSION << 5) | (msg_type & 0x1F)
crc32 = CRC-32/IEEE over [ver_type, seq, payload…]
- COBS framing with a
0x00delimiter (the only zero on the wire) — the host resynchronizes on the next delimiter after any garbage. - CRC-32/IEEE over the header + payload catches corruption (the same primitive the EEPROM KV store uses).
- postcard for the payload — compact and
no_std, but not self-describing, so both ends must share the exact struct/enum definitions. That's whytower-protocolis one crate, version-pinned. seq(writer-assigned) lets the host detect real wire loss.
Message types (the low 5 bits of ver_type; target→host are 0..15, host→target 16+):
| Type | # | Dir | Payload |
|---|---|---|---|
Hello |
0 | T→H | protocol + firmware version, firmware_name, per-boot session_id (boot announce) |
Log |
1 | T→H | level, uptime_us, module, message |
Print |
2 | T→H | raw text |
Event |
3 | T→H | name + (key, value) pairs (wire allows 8; the SDK emits ≤6) |
ShellResponse |
4 | T→H | cmd_id, result, chunk, last, text |
ShellCompletions |
5 | T→H | req_id, token_start, common_prefix, candidates, more |
Dropped |
6 | T→H | count of dropped frames |
MgmtResponse |
7 | T→H | req_id, result, chunk, last, data (chunked mgmt reply; wire v3 gateway link) |
Uplink |
8 | T→H | src, counter, rssi, lqi, data (gateway forwards a node's radio payload verbatim) |
RadioStat |
9 | T→H | channel RSSI sample, or a TX-delivery outcome |
ShellCommand |
16 | H→T | cmd_id, line |
ShellComplete |
17 | H→T | req_id, line, cursor |
MgmtRequest |
18 | H→T | req_id, op (a mgmt::MgmtOp; wire v3 gateway link) |
A receiver feeds bytes to a FrameDecoder until it yields a deframed buffer, then
decode_frame checks version + type + CRC and returns (MsgType, seq, payload).
| Constant | Value | Meaning |
|---|---|---|
PROTOCOL_VERSION |
3 | top 3 bits of ver_type (caps at 7; bumped to 3 for the wire-v3 gateway link) |
MAX_FRAME |
256 | inner frame, pre-COBS (payload budget ≈ 249) |
MAX_WIRE |
272 | COBS-expanded frame + delimiter |
MAX_MSG |
192 | log / print text |
SHELL_CHUNK |
192 | shell-response text per frame (chunked) |
MAX_SETTING |
64 | largest setting value |
| TX queue depth | 8 | producer → writer channel |
| Code | Name | Meaning |
|---|---|---|
| 0 | R_OK |
success |
| 1 | R_NOT_FOUND |
no such command / setting |
| 2 | R_BAD_ARG |
bad / out-of-range value |
| 3 | R_STORAGE |
EEPROM write failed |
| 4 | R_TRUNCATED |
response body overflowed the cap and was truncated |
tower::console:
event(name, &[(k, v)]).await— emit a structured eventprint!/println!— raw text (re-exported macros)install_logger(max_level),manager(usart1, tx_pin, rx_pin, vbus),boot_banner(name)— wiring for the dynamic (USB-gated) console, handled for you byBoard::take/app!
tower::shell:
serve(spawner, kv)/serve_ext(spawner, kv, app_commands, app_settings)- types:
Entry(::cmd/::menu),Command,Args(None/Names/Settings),Setting,Kind,SettingsTable,Ctx,Outcome(::ok/::code),Handler - constants:
MAX_SETTING,R_OK/R_NOT_FOUND/R_BAD_ARG/R_STORAGE/R_TRUNCATED
tower_protocol: encode_frame, decode_frame, FrameDecoder, MsgType, the payload
structs, PROTOCOL_VERSION, MAX_FRAME, MAX_WIRE, crc::{crc32_update, crc32_ieee}.
| Example | Shows |
|---|---|
console_demo |
every log level + println! + truncation + the Dropped marker |
console_panic |
the framed panic path (tower logs shows the panic record) |
events_demo |
structured events interleaved with logs |
shell_demo |
the shell: built-ins, settings of every kind, an app command + nested subtree, app settings |
console_full |
the showcase for tower console — logs + events + shell together |
Flash any of them, e.g. TOWER_DEVICE=/dev/cu.usbserial-140 just flash example shell_demo,
then drive with tower shell / tower logs / tower console.
The wire codec lives in its own repo now
(tower-protocol); its host tests run there:
cargo test # in a tower-protocol checkoutThese cover round-trips for every message, boundary frame sizes, the decoder's
overflow/reset/resync state machine, exhaustive type mapping, and 9000 deterministic
bit-flip fuzz iterations proving every single-bit corruption is detected. (They run on the
host; the crate itself is no_std and also builds for thumbv6m.) The firmware-side paths
(logs, events, shell, settings, completion, panic, persistence) are verified on the dongle.
- DMA (RX overrun fixed): the console now uses DMA — TX on
DMA1_CH4, RX on a circular DMA buffer onDMA1_CH5(a different channel group from the WS2812 strip'sDMA1_CH3, so both coexist). The hardware-filled RX ring removed the old ~1% host → target loss that the interrupt-per-byte path suffered during a TX flood. The writer still holds aWakeGuardper burst — now belt-and-braces (the RX ring already holds the STOP inhibit while the console is up). fw ?in the header appears only if you attachtower consoleand never see aHello— the firmware announces it on boot and on each USB plug-in (when the dynamic console rebuilds), not on host connect. The TUI header otherwise shows thetowerCLI version; the firmware version is in/system/resource print.VBUS_SENSE(PA12): the dynamic console gates on PA12 reading logic-high when USB is plugged. PA12 is driven by the FT231X's CBUS3 pin (a push-pull ~3.3 V logic output configured in the FTDI EEPROM — e.g.DRIVE1on the Core Module,SLEEP#on the Radio Dongle) — not a resistor divider off USB 5 V. Two consequences: (1) CBUS3 asserts only tens of ms after power-up, which is why the manager also polls VBUS every ~500 ms (a missed rising edge still brings the console up within ½ s); (2) if CBUS3 is mis-configured in the EEPROM, PA12 won't reach the STM32's V_IH and the console won't come up — scope PA12 on plug to confirm. The pin uses an internal pull-down so it reads a defined low when unplugged (no false "present"). PA12 is also the USB DP pin, used here purely as a VBUS-sense GPIO (no USB device peripheral is enabled).- Setting values can't contain
/, space, or tab — those are command tokenizers. Enum values dodge this by being a fixed set. - postcard is not self-describing: any change to a payload struct/enum is a wire
change — bump
PROTOCOL_VERSIONand re-tagtower-protocol; both ends (firmware + host) pin the new tag in lockstep. Today's tag isv1.3.0. - A response is capped at
MAX_SETTING/MAX_RESP-sized text before chunking; very large dumps are clipped. Raise the caps (andMAX_RESP) if you need more.