Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,10 @@ Decided 2026-08-24, **built 2026-08-27**. Recorded so it is not re-litigated:
`TransportSettings` before anything connects. Under the orchestrator, JMRI auto-connect is
never started; under WiThrottle, no orchestrator task exists. The device must never sit
retrying a server the operator did not choose.
- **The UI asks `ThrottleController`, never a concrete client.** Connection state, knob
gating, functions and track power all come through the port. Reaching past it into
`WiThrottleClient` or `JmriJsonClient` is what made the orchestrator transport look dead
on its first bench run — both are disconnected when the orchestrator is selected.
- **`setSpeedAndDirection` is the call to use when both change.** `THROTTLE_COMMAND` carries
the pair, and sending speed against the *old* direction first commands the loco faster the
way it was already going before reversing it. The port's default implementation orders it
Expand Down Expand Up @@ -184,6 +188,10 @@ Things that look like bugs or oversights and are not. One line each.
turned would move a model that no longer tracks anything real.
- **`sdkconfig.tests` is tracked despite appearing in `.gitignore`** — it predates the
ignore rule. `sdkconfig.test.defaults` is the file that actually matters.
- **`CONFIG_WS_BUFFER_SIZE=16384` is not oversizing.** It sizes the WebSocket *handshake*
buffer, not the frame buffer, and must hold the `101` plus whatever of the orchestrator's
opening `STATE_SNAPSHOT` arrives in the same TCP read. Lowering it brings back an
intermittent `Header size exceeded buffer size` that kills the connection outright.

## Open limits

Expand Down
8 changes: 5 additions & 3 deletions docs/architecture/THREADING_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ The ESP32-S3 is dual-core. LVGL rendering runs on a dedicated task; network I/O
| `orch_connect` | 6 KB | 5 | Wait for WiFi → orchestrator login → fetch roster | `AppController::startOrchestratorConnectTask()` |
| `orch_ui_conn` | 6 KB | 5 | Same, triggered by the config screen's Connect button | `OrchestratorConfigScreen::onConnectClicked()` |
| `websocket_task` | 6 KB | — | Orchestrator control-plane receive loop (owned by `esp_websocket_client`) | `OrchestratorClient::connect()` |
| `track_power` | 4 KB | 5 | One-shot track-power write | `ThrottleController::requestTrackPower()` |

`throttle_poll` is created **only when the active `ThrottleBackend` reports
`requiresPolling()`**. WiThrottle does, because it answers queries rather than volunteering
Expand All @@ -31,9 +32,10 @@ task is created at all and its 4 KB stack is never allocated.
no orchestrator task does; under the orchestrator, `orch_connect` starts and JMRI
auto-connect is never begun. Nothing sits retrying a server the operator has not chosen.

`orch_connect` and `orch_ui_conn` both exist because the orchestrator login is a **blocking
HTTP round trip** and the roster is two more. None of that may happen on the LVGL task
(F-05). Both are one-shot: they delete themselves when done.
`orch_connect`, `orch_ui_conn` and `track_power` all exist for the same reason: the
orchestrator's login, roster read and power command are **blocking HTTP round trips**, and
none of that may happen on the LVGL task (F-05). `track_power` in particular is spawned
straight from a button handler. All three are one-shot and delete themselves when done.

---

Expand Down
51 changes: 47 additions & 4 deletions docs/components/COMMUNICATION_LAYER.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,22 @@ and that difference surfaces here rather than as a fake session.
| `providesRoster()` | No selectable roster — the controller must not offer loco selection |
| `providesFunctionLabels()` | UI falls back to `F0`…`F28` |
| `requiresPolling()` | State arrives unprompted; no `throttle_poll` task is created |
| `supportsTrackPower()` | The power button is **hidden**, not left dead |

### Track power lives on this port too

Strictly a layout command rather than a throttle one, but it rides the same
connection and the UI needs one place to ask — so it is here rather than in a second
port with two more adapters.

`TrackPower::UNKNOWN` is **not** "off". It means nothing has told us yet, and showing it as
off would claim the rails are dead when nobody knows. The UI renders it as its own state.

Everything the UI needs — connection state, knob gating, functions, track power — comes
through `ThrottleController` and this port. Reaching past it into a concrete client is the
bug that made the orchestrator transport look dead: the knobs were gated on
`WiThrottleClient::isConnected()`, and the power bar read `JmriJsonClient` directly, neither
of which is connected when the orchestrator is the selected transport.

### Threading

Expand Down Expand Up @@ -117,11 +133,38 @@ Refused, each with a log line and no callback:

Within a `STATE_SNAPSHOT`, one bad loco entry is skipped without costing the rest.

### Roster
### Roster and track power are REST, not WebSocket

The `ClientMessage` union has no track-power member and the snapshot carries loco state keyed
by address but no names, so both are HTTP:

| Need | Call |
|------|------|
| Roster | `GET /api/layouts/{id}/locos` |
| Track power | `POST /api/layouts/{id}/dcc-link/power` with `{"on": bool}` |

The layout id comes from `GET /api/layouts`, fetched once and cached. The roster is built
aside and swapped in, so a partly-built roster is never visible to the carousel.

The power POST's **reply body is deliberately ignored**. The `DCC_LINK` event pushed the
moment it lands is what tells us the truth — that is the route's own contract, not our
preference. `DCC_LINK` is also read off the snapshot, so the button is right from the first
frame rather than waiting for the next change.

### CONFIG_WS_BUFFER_SIZE, and why it is not the client's buffer_size

`esp_websocket_client_config_t::buffer_size` is the **frame** buffer. The HTTP Upgrade
handshake is built and read in a *separate* buffer sized by `CONFIG_WS_BUFFER_SIZE`
(`sdkconfig.defaults`). Raising the former does nothing for the latter.

`transport_ws` reads until it finds the header terminator, then **still fails** if the buffer
filled. The orchestrator pushes a whole-layout `STATE_SNAPSHOT` the instant the socket opens,
so the `101` and a chunk of that snapshot regularly arrive in one TCP read — which is why
`transport_ws: Header size exceeded buffer size` was intermittent rather than constant. It
depends on packet timing, not on header length.

A REST read (`GET /api/layouts/{id}/locos`), not a control-plane message: the snapshot
carries loco state keyed by address but no names. The layout id comes from `GET /api/layouts`.
Built aside and swapped in, so a partly-built roster is never visible to the carousel.
Set to **16384**. It must exceed the response *plus* whatever of the first frame arrives with
it, so a layout that grows enough to inflate the snapshot could eventually need more.

---

Expand Down
19 changes: 19 additions & 0 deletions docs/components/UI_LAYER.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,25 @@ task would freeze every throttle at once (F-05).

## UI Components

### PowerStatusBar

**File:** `main/ui/components/PowerStatusBar.cpp/h`

**Purpose:** Track power button and link status, driven by the **active transport** through
`ThrottleController` — never by a concrete client.

It previously read `JmriJsonClient` directly, so under the orchestrator transport the button
did nothing and the label read "Disconnected" while the layout was in fact connected.

- A transport answering `supportsTrackPower() == false` gets the button **hidden**, not left
dead for the operator to press and wonder about.
- `TrackPower::UNKNOWN` renders as its own state ("Power ?"), not as off.
- The press returns immediately: the orchestrator's power command is a blocking HTTP round
trip, so the write happens on a short-lived task (F-05). The button repaints when the
layout says power changed, not when we asked.

---

### ThrottleMeter

**File:** `main/ui/components/ThrottleMeter.cpp/h`
Expand Down
54 changes: 54 additions & 0 deletions main/communication/OrchestratorBackend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ OrchestratorBackend::~OrchestratorBackend()
if (m_client) {
m_client->setLocoStateCallback(nullptr);
m_client->setConnectionStateCallback(nullptr);
m_client->setTrackPowerCallback(nullptr);
}
if (m_mutex) {
vSemaphoreDelete(m_mutex);
Expand Down Expand Up @@ -379,6 +380,59 @@ void OrchestratorBackend::setConnectionStateCallback(ConnectionStateCallback cal
});
}

esp_err_t OrchestratorBackend::setTrackPower(bool on)
{
if (!m_client) {
return ESP_ERR_NOT_SUPPORTED;
}
// Blocking HTTP. The caller is responsible for not being the LVGL task.
return m_client->setTrackPower(on);
}

ThrottleBackend::TrackPower OrchestratorBackend::getTrackPower() const
{
if (!m_client) {
return TrackPower::UNKNOWN;
}
switch (m_client->getTrackPower()) {
case OrchestratorClient::TrackPower::ON: return TrackPower::ON;
case OrchestratorClient::TrackPower::OFF: return TrackPower::OFF;
default: return TrackPower::UNKNOWN;
}
}

void OrchestratorBackend::setTrackPowerCallback(TrackPowerCallback callback)
{
m_trackPowerCallback = std::move(callback);

if (!m_client) {
return;
}

if (!m_trackPowerCallback) {
m_client->setTrackPowerCallback(nullptr);
return;
}

m_client->setTrackPowerCallback(
[this](OrchestratorClient::TrackPower state) {
if (!m_trackPowerCallback) {
return;
}
switch (state) {
case OrchestratorClient::TrackPower::ON:
m_trackPowerCallback(TrackPower::ON);
break;
case OrchestratorClient::TrackPower::OFF:
m_trackPowerCallback(TrackPower::OFF);
break;
default:
m_trackPowerCallback(TrackPower::UNKNOWN);
break;
}
});
}

void OrchestratorBackend::setFunctionLabelsCallback(FunctionLabelsCallback /*callback*/)
{
// Never invoked: providesFunctionLabels() is false, so ThrottleController
Expand Down
6 changes: 6 additions & 0 deletions main/communication/OrchestratorBackend.h
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ class OrchestratorBackend : public ThrottleBackend {
void setFunctionLabelsCallback(FunctionLabelsCallback callback) override;
void setConnectionStateCallback(ConnectionStateCallback callback) override;

bool supportsTrackPower() const override { return m_client != nullptr; }
esp_err_t setTrackPower(bool on) override;
TrackPower getTrackPower() const override;
void setTrackPowerCallback(TrackPowerCallback callback) override;

private:
/**
* @brief What this device currently has on each throttle.
Expand Down Expand Up @@ -91,6 +96,7 @@ class OrchestratorBackend : public ThrottleBackend {

ThrottleStateCallback m_throttleStateCallback;
ConnectionStateCallback m_connectionStateCallback;
TrackPowerCallback m_trackPowerCallback;

mutable SemaphoreHandle_t m_mutex;
};
Loading