Add Svelte GUI, FIT encoder and README
Standalone binary embeds the frontend, avoiding the dev-server dependency that made the window fail to load. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,374 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Connection screen (FR-9.1–9.3).
|
||||
*
|
||||
* The one thing this screen must get right: **connected is not
|
||||
* controllable**. A trainer can be attached at the BLE level and still refuse
|
||||
* the FTMS control point, in which case nothing you do moves the resistance.
|
||||
* So every trainer row carries two independent states, side by side, and the
|
||||
* ride screen stays gated until control is real.
|
||||
*/
|
||||
import { app } from '../lib/app.svelte';
|
||||
import { api } from '../lib/bridge';
|
||||
import { connectionText, rssiBars } from '../lib/format';
|
||||
import type { DeviceInfo, DeviceKind } from '../lib/types';
|
||||
|
||||
const devices = $derived(app.devices.devices);
|
||||
const scanning = $derived(app.devices.scanning);
|
||||
const trainerReady = $derived(
|
||||
devices.some((d) => d.kind === 'trainer' && d.controlAcquired),
|
||||
);
|
||||
|
||||
const KIND_LABEL: Record<DeviceKind, string> = {
|
||||
trainer: 'Smart trainer · FTMS',
|
||||
clickLeft: 'Zwift Click · left pod',
|
||||
clickRight: 'Zwift Click · right pod',
|
||||
heartRate: 'Heart rate monitor',
|
||||
unknown: 'Unidentified',
|
||||
};
|
||||
|
||||
function isConnected(d: DeviceInfo): boolean {
|
||||
return d.state === 'Connected' || d.state === 'Controlling';
|
||||
}
|
||||
|
||||
function busy(d: DeviceInfo): boolean {
|
||||
return d.state === 'Connecting' || d.state === 'Reconnecting';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="screen">
|
||||
<header>
|
||||
<div>
|
||||
<h1>Devices</h1>
|
||||
<p>
|
||||
{#if scanning}
|
||||
Scanning for Bluetooth peripherals…
|
||||
{:else}
|
||||
Scan stopped.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
{#if scanning}
|
||||
<button class="btn ghost" onclick={() => app.run(() => api.stopScan())}>Stop scan</button>
|
||||
{: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'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#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
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="list">
|
||||
{#each devices as device (device.id)}
|
||||
{@const conn = connectionText(device.state)}
|
||||
{@const bars = rssiBars(device.rssi)}
|
||||
<article class="row" class:live={isConnected(device)}>
|
||||
<div class="identity">
|
||||
<span class="name">{device.name}</span>
|
||||
<span class="meta">{KIND_LABEL[device.kind]} · {device.address}</span>
|
||||
{#if device.error}
|
||||
<span class="error">{device.error}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="signal" title="{device.rssi} dBm">
|
||||
<span class="bars">
|
||||
{#each [1, 2, 3, 4] as bar}
|
||||
<i class:on={bar <= bars} style:height="{bar * 25}%"></i>
|
||||
{/each}
|
||||
</span>
|
||||
<span class="dbm">{device.rssi}</span>
|
||||
</div>
|
||||
|
||||
<!-- Two states, never conflated (FR-9.3). -->
|
||||
<div class="states">
|
||||
<span class="state">
|
||||
<span class="label">Bluetooth</span>
|
||||
<span class="value {conn.tone === 'ok' ? 'tone-ok' : `tone-${conn.tone}`}">
|
||||
<span class="dot"></span>{busy(device) ? conn.label + '…' : conn.label}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{#if device.kind === 'trainer'}
|
||||
<span class="state">
|
||||
<span class="label">FTMS control</span>
|
||||
<span class="value" class:tone-ok={device.controlAcquired} class:tone-warn={!device.controlAcquired}>
|
||||
<span class="dot"></span>{device.controlAcquired ? 'Acquired' : 'Not acquired'}
|
||||
</span>
|
||||
</span>
|
||||
{:else if device.kind === 'clickLeft' || device.kind === 'clickRight'}
|
||||
<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>
|
||||
</span>
|
||||
{:else if device.batteryPct != null}
|
||||
<span class="state">
|
||||
<span class="label">Battery</span>
|
||||
<span class="value tone-idle"><span class="dot"></span>{device.batteryPct}%</span>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
{#if isConnected(device)}
|
||||
<button class="btn ghost" onclick={() => app.run(() => api.disconnect(device.id))}>
|
||||
Disconnect
|
||||
</button>
|
||||
{:else}
|
||||
<button class="btn" disabled={busy(device)} onclick={() => app.run(() => api.connect(device.id))}>
|
||||
{busy(device) ? 'Connecting…' : 'Connect'}
|
||||
</button>
|
||||
{/if}
|
||||
<button class="btn ghost danger" onclick={() => app.run(() => api.forget(device.id))}>
|
||||
Forget
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
|
||||
{#if devices.length === 0}
|
||||
<!-- A-4 / FR-1.8: absent is not the same as missing. -->
|
||||
<div class="empty">
|
||||
<h2>Nothing found yet</h2>
|
||||
<p>Most devices sleep until you touch them. To wake them:</p>
|
||||
<ul>
|
||||
<li><strong>Trainer</strong> — turn the pedals for a few seconds.</li>
|
||||
<li><strong>Zwift Click</strong> — press any button on the pod.</li>
|
||||
<li><strong>Heart rate strap</strong> — wet the contacts and put it on.</li>
|
||||
</ul>
|
||||
<p class="quiet">
|
||||
They will appear here as soon as they advertise. Scanning continues in the background.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.screen {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--gap);
|
||||
padding: clamp(1.4rem, 3vw, 2.6rem) var(--edge) 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.6rem, 2.6vw, 2.4rem);
|
||||
font-weight: 300;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
header p {
|
||||
margin: 0.2rem 0 0;
|
||||
color: var(--ink-dim);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.gate {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin: 0 var(--edge) 0.6rem;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border-radius: 0.55rem;
|
||||
background: rgba(255, 207, 74, 0.06);
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.gate strong {
|
||||
color: var(--warn);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.list {
|
||||
overflow-y: auto;
|
||||
padding: 0.4rem var(--edge) 2rem;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.5fr) auto minmax(0, 1.4fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--gap);
|
||||
padding: 1rem 0.25rem;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
|
||||
.row.live .name {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.18rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 1.12rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: 0.82rem;
|
||||
color: var(--bad);
|
||||
}
|
||||
|
||||
.signal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
height: 1.15rem;
|
||||
}
|
||||
|
||||
.bars i {
|
||||
width: 3px;
|
||||
background: var(--ink-faint);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.bars i.on {
|
||||
background: var(--ok);
|
||||
}
|
||||
|
||||
.dbm {
|
||||
font-size: 0.78rem;
|
||||
color: var(--ink-dim);
|
||||
min-width: 2.3em;
|
||||
}
|
||||
|
||||
.states {
|
||||
display: flex;
|
||||
gap: var(--gap);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.state .value {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.empty {
|
||||
max-width: 42rem;
|
||||
margin: clamp(2rem, 8vh, 6rem) auto;
|
||||
text-align: left;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.empty h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 300;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.empty ul {
|
||||
margin: 0.6rem 0;
|
||||
padding-left: 1.1rem;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
.empty strong {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quiet {
|
||||
color: var(--ink-faint);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.row {
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-areas:
|
||||
'identity signal'
|
||||
'states states'
|
||||
'controls controls';
|
||||
}
|
||||
.identity {
|
||||
grid-area: identity;
|
||||
}
|
||||
.signal {
|
||||
grid-area: signal;
|
||||
}
|
||||
.states {
|
||||
grid-area: states;
|
||||
}
|
||||
.controls {
|
||||
grid-area: controls;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user