Ride the drivetrain, command the load in watts
Speed now comes from the drivetrain and the load from the road, which is the way round a bike actually works. Speed is cadence x development, filtered lightly. Power, not cadence, decides whether the rider is driving it: on a direct-drive trainer the flywheel keeps the cranks turning after they stop, so cadence alone reads a healthy 80 rpm for someone doing nothing. Below 15 W the speed runs down to whatever the gradient sustains on no power - zero uphill, a real freewheeling speed on a descent. Stopping on a 3.5% climb used to settle at 22 km/h and stay there, because the model wanted to decelerate and a blend toward the flywheel speed outvoted it; that blend is gone. The D100 sends no cadence over FTMS - it is a rebadged Magene T110 with cadence disabled in firmware (qdomyos-zwift#3282) - so it is inferred from wheel speed, which one sprocket and no freewheel make exact. Its Zwift channel does carry cadence, and is now greeted with RideOn and subscribed on every notifying characteristic, so a measured value is used where one arrives. The load is commanded as power, not gradient. The trainer declares 50-600 W in 1 W steps against 0-6% inclination in 0.1% steps refusing negatives, and whether it acts on 0x11 at all is still unconfirmed. Its power target is a ceiling rather than a setpoint, which is very nearly what a road is: exceed it and the surplus becomes speed. Gravity travels on the same channel as watts, so nothing is lost by leaving 0x11 alone. LoadChannel keeps the gradient path selectable and tested. Virtual shifting reaches the trainer for the first time. The physics load model was written but never called, and a paddle press both shifted a gear in Rust and nudged the gradient in the webview - the shift silently, the tilt visibly, so the paddles looked like a gradient trim. Also: a fixed 12 W drivetrain loss, held as a power because that is how it presents; crank length, so a gear can be reported as the force it puts under the foot; gear and pedal force on the ride screen; a drag-race profile for testing gearing on the flat. Two readout bugs fixed on the way. The rolling windows were trimmed by timestamp but fed on a fixed timer, so every second spent on the ride screen before starting pushed samples at t=0 that could never expire - speed read a fraction of the truth for the first 45 s. And the headline speed was a 45 s mean, which took most of a minute to show a gear change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,17 +12,18 @@
|
||||
import { api } from '../lib/bridge';
|
||||
import { connectionText, rssiBars } from '../lib/format';
|
||||
import type { DeviceInfo, DeviceKind } from '../lib/types';
|
||||
import ClickPanel from './ClickPanel.svelte';
|
||||
|
||||
const devices = $derived(app.devices.devices);
|
||||
const scanning = $derived(app.devices.scanning);
|
||||
const trainerReady = $derived(
|
||||
devices.some((d) => d.kind === 'trainer' && d.controlAcquired),
|
||||
);
|
||||
const trainerReady = $derived(app.trainerReady);
|
||||
|
||||
const KIND_LABEL: Record<DeviceKind, string> = {
|
||||
trainer: 'Smart trainer · FTMS',
|
||||
clickLeft: 'Zwift Click · left pod',
|
||||
clickRight: 'Zwift Click · right pod',
|
||||
// Named for the shift paddle, which is printed on the pod — unlike left and
|
||||
// right, which nothing in the advertisement actually tells us (§2.3.1).
|
||||
clickMinus: 'Zwift Click · − pod',
|
||||
clickPlus: 'Zwift Click · + pod',
|
||||
heartRate: 'Heart rate monitor',
|
||||
unknown: 'Unidentified',
|
||||
};
|
||||
@@ -54,48 +55,33 @@
|
||||
{:else}
|
||||
<button class="btn ghost" onclick={() => app.run(() => api.startScan())}>Scan</button>
|
||||
{/if}
|
||||
<button class="btn primary" onclick={() => (app.screen = 'ride')}>
|
||||
{trainerReady ? 'Go to ride' : 'Ride without a trainer'}
|
||||
<!-- Disabled until control is real: there is no ride without a trainer,
|
||||
and offering one would be offering a session that records nothing. -->
|
||||
<button
|
||||
class="btn primary"
|
||||
disabled={!trainerReady}
|
||||
title={trainerReady ? '' : 'Connect a trainer and acquire FTMS control first'}
|
||||
onclick={() => app.goToRide()}
|
||||
>
|
||||
Go to ride
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!--
|
||||
The Zwift Click is deliberately not in the device list below: that list is
|
||||
FTMS trainers, and a controller is a different kind of thing with a
|
||||
different failure mode (it sleeps in seconds and must be woken by hand).
|
||||
The Click gets a panel of its own above the device list rather than two more
|
||||
rows in it. It is a different kind of thing with a different failure mode —
|
||||
two pods that sleep in seconds and must be woken by hand — and the one thing
|
||||
the old single line could not do was say *which* pod was missing (FR-1.4).
|
||||
-->
|
||||
<div class="gate">
|
||||
<span class="dot {app.controller?.connected ? 'tone-ok' : 'tone-warn'}"></span>
|
||||
<span>
|
||||
{#if app.controller?.connected}
|
||||
Zwift Click connected{app.controller.batteryPercent != null
|
||||
? ` — battery ${app.controller.batteryPercent}%`
|
||||
: ''}. Paddles shift; the D-pad drives the UI.
|
||||
{:else if app.controller?.error}
|
||||
Controller: {app.controller.error}
|
||||
{:else}
|
||||
No controller. <strong>Press a button on the Click first</strong> — it only advertises
|
||||
while awake.
|
||||
{/if}
|
||||
</span>
|
||||
{#if app.controller?.connected}
|
||||
<button class="btn ghost" onclick={() => app.run(() => api.disconnectController())}>
|
||||
Disconnect
|
||||
</button>
|
||||
{:else}
|
||||
<button class="btn ghost" onclick={() => app.run(() => api.connectController())}>
|
||||
Connect Click
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<ClickPanel />
|
||||
|
||||
{#if !trainerReady}
|
||||
<div class="gate">
|
||||
<span class="dot tone-warn"></span>
|
||||
<span
|
||||
>No trainer under control yet. The ride screen will show <strong>simulated</strong>
|
||||
telemetry until an FTMS trainer accepts the control point.</span
|
||||
>No trainer under control yet. The ride screen stays <strong>locked</strong> until an FTMS
|
||||
trainer accepts the control point.</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -138,20 +124,20 @@
|
||||
<span class="dot"></span>{device.controlAcquired ? 'Acquired' : 'Not acquired'}
|
||||
</span>
|
||||
</span>
|
||||
{:else if device.kind === 'clickLeft' || device.kind === 'clickRight'}
|
||||
{:else if device.kind === 'clickMinus' || device.kind === 'clickPlus'}
|
||||
<!--
|
||||
Which pod, and what the panel above says about its link. The
|
||||
unlock countdown that used to sit here was fiction: nothing
|
||||
reports how much of the ~24 h unlock is left, so it rendered
|
||||
"expired" against a pod that was working perfectly.
|
||||
-->
|
||||
{@const pod = device.kind === 'clickPlus' ? app.controller?.plus : app.controller?.minus}
|
||||
<span class="state">
|
||||
<span class="label">Zwift unlock</span>
|
||||
<span
|
||||
class="value"
|
||||
class:tone-ok={(device.unlockExpiresInS ?? 0) > 0}
|
||||
class:tone-bad={(device.unlockExpiresInS ?? 0) <= 0}
|
||||
>
|
||||
<span class="dot"></span>
|
||||
{#if (device.unlockExpiresInS ?? 0) > 0}
|
||||
{Math.round((device.unlockExpiresInS ?? 0) / 3600)} h left
|
||||
{:else}
|
||||
Expired — re-unlock in Zwift
|
||||
{/if}
|
||||
<span class="label">Click pod</span>
|
||||
<span class="value" class:tone-ok={pod?.state === 'connected'} class:tone-idle={pod?.state !== 'connected'}>
|
||||
<span class="dot"></span>{pod?.symbol ?? '?'} pod{pod?.state === 'connected'
|
||||
? ' · linked'
|
||||
: ''}
|
||||
</span>
|
||||
</span>
|
||||
{:else if device.batteryPct != null}
|
||||
|
||||
Reference in New Issue
Block a user