A central with two lifecycles: an adapter/scan machine (doubly gated by Permission + a powered-on radio) and a per-peripheral link machine, with a bespoke specimen mocking the scanner and connected-device screens.
482 lines
12 KiB
Vue
482 lines
12 KiB
Vue
<script setup lang="ts">
|
|
import { computed } from "vue"
|
|
|
|
const props = defineProps<{ activeId: string }>()
|
|
|
|
// BLE has a real central-side UI, so the specimen mocks its two screens — the
|
|
// "Nearby Devices" scanner and a connected peripheral's GATT view — and moves
|
|
// the highlight per function, the same annotate-one-surface approach List/Grid/
|
|
// Calendar use. Below the device, a state bar shows the central's own machinery:
|
|
// the doubly-gated (permission + adapter) scan and the per-peripheral link.
|
|
|
|
type Screen = "scanning" | "connected"
|
|
type Region = "permission" | "power" | "scan" | "connect" | "services" | "exchange" | "validate"
|
|
|
|
interface Frame {
|
|
screen: Screen
|
|
hl?: Region
|
|
tag?: string
|
|
note?: string
|
|
perm: string
|
|
adapter: string
|
|
scan: string
|
|
link?: string
|
|
}
|
|
|
|
const frame = computed<Frame>(() => {
|
|
switch (props.activeId) {
|
|
case "permission":
|
|
return {
|
|
screen: "scanning",
|
|
hl: "permission",
|
|
tag: "◈ PERMISSION",
|
|
perm: "NotAsked → Granted",
|
|
adapter: "PoweredOn",
|
|
scan: "ScanIdle",
|
|
note: "requestPermission() → OS dialog → grant(). scan = Scanning requires permission = Granted — the first of two gates. deny() also forces scan ← ScanIdle.",
|
|
}
|
|
case "power":
|
|
return {
|
|
screen: "scanning",
|
|
hl: "power",
|
|
tag: "◈ POWER",
|
|
perm: "Granted",
|
|
adapter: "PoweredOff → PoweredOn",
|
|
scan: "ScanIdle",
|
|
note: "The radio is the second scan gate. powerOff() (user toggles Bluetooth off) forces scan ← ScanIdle and clears discovered — every link drops.",
|
|
}
|
|
case "scan":
|
|
return {
|
|
screen: "scanning",
|
|
hl: "scan",
|
|
tag: "◈ SCAN",
|
|
perm: "Granted",
|
|
adapter: "PoweredOn",
|
|
scan: "Scanning",
|
|
note: "startScan() [Granted + PoweredOn] → Scanning; discover(p) accumulates advertisers into the discovered set. Pair with stopScan(), or a continuous radio scan drains the battery.",
|
|
}
|
|
case "connect":
|
|
return {
|
|
screen: "connected",
|
|
hl: "connect",
|
|
tag: "◈ CONNECT",
|
|
perm: "Granted",
|
|
adapter: "PoweredOn",
|
|
scan: "ScanIdle",
|
|
link: "Disconnected → Connecting → Connected",
|
|
note: "connect(p) → Connecting; established(p) → Connected, or connectFail() back to Disconnected. disconnect() / drop returns to Disconnected and invalidates every cached handle.",
|
|
}
|
|
case "discover-services":
|
|
return {
|
|
screen: "connected",
|
|
hl: "services",
|
|
tag: "◈ DISCOVER",
|
|
perm: "Granted",
|
|
adapter: "PoweredOn",
|
|
scan: "ScanIdle",
|
|
link: "Connected → Ready",
|
|
note: "discoverServices(p) enumerates GATT services/characteristics → link ← Ready. Handles exist only in Ready; a drop clears them, so they must be re-discovered after every reconnect.",
|
|
}
|
|
case "exchange":
|
|
return {
|
|
screen: "connected",
|
|
hl: "exchange",
|
|
tag: "◈ EXCHANGE",
|
|
perm: "Granted",
|
|
adapter: "PoweredOn",
|
|
scan: "ScanIdle",
|
|
link: "Ready",
|
|
note: "On a Ready link: subscribe(ch) for live notifications, or a one-shot read/write — against discovered characteristics only. subscriptions ⊆ characteristics.",
|
|
}
|
|
case "validate":
|
|
return {
|
|
screen: "connected",
|
|
hl: "validate",
|
|
tag: "◈ VALIDATE",
|
|
perm: "Granted",
|
|
adapter: "PoweredOn",
|
|
scan: "ScanIdle",
|
|
link: "Ready",
|
|
note: "The peripheral is untrusted: bounds-check every payload before parsing. Raw MAC, unparsed bytes, and the device name never reach analytics or your backend — only derived, validated values do.",
|
|
}
|
|
default:
|
|
return {
|
|
screen: "scanning",
|
|
perm: "Granted",
|
|
adapter: "PoweredOn",
|
|
scan: "Scanning",
|
|
note: "A central: doubly-gated scanning (permission + radio) discovers peripherals; connect to one, discover its GATT characteristics, then read / write / subscribe over a Ready link that may drop at any time.",
|
|
}
|
|
}
|
|
})
|
|
|
|
const peripherals = [
|
|
{ name: "Polar H10", addr: "A1:2F", rssi: "-54 dBm" },
|
|
{ name: "Mi Scale 2", addr: "9C:8E", rssi: "-71 dBm" },
|
|
]
|
|
|
|
const isHl = (r: Region) => frame.value.hl === r
|
|
const tagOf = (r: Region) => (frame.value.hl === r ? frame.value.tag : undefined)
|
|
</script>
|
|
|
|
<template>
|
|
<div class="specimen">
|
|
<p v-if="frame.note" class="note">{{ frame.note }}</p>
|
|
|
|
<div class="device">
|
|
<div class="dev-bar">
|
|
<span class="dot" /><span class="dot" /><span class="dot" />
|
|
<span class="dev-title">MyApp · Bluetooth</span>
|
|
</div>
|
|
|
|
<!-- SCANNING screen -->
|
|
<div v-if="frame.screen === 'scanning'" class="screen">
|
|
<div class="statusrow">
|
|
<span
|
|
class="chip on"
|
|
:class="isHl('power') ? ['hl', 'tag-b'] : []"
|
|
:data-tag="tagOf('power')"
|
|
>
|
|
📶 Radio · On
|
|
</span>
|
|
<span
|
|
class="chip on"
|
|
:class="isHl('permission') ? ['hl', 'tag-b'] : []"
|
|
:data-tag="tagOf('permission')"
|
|
>
|
|
🔓 Permission · Granted
|
|
</span>
|
|
</div>
|
|
|
|
<div class="listbox" :class="isHl('scan') ? ['hl', 'tag-b'] : []" :data-tag="tagOf('scan')">
|
|
<div class="list-head">
|
|
<span>Nearby Devices</span>
|
|
<span class="scanning"><span class="spin">⟳</span> scanning</span>
|
|
</div>
|
|
<div v-for="p in peripherals" :key="p.addr" class="dev-row">
|
|
<span class="d-name">{{ p.name }}</span>
|
|
<span class="d-addr">{{ p.addr }}</span>
|
|
<span class="d-rssi">{{ p.rssi }}</span>
|
|
<span class="connect-btn">connect</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- CONNECTED screen -->
|
|
<div v-else class="screen">
|
|
<div
|
|
class="conn-head"
|
|
:class="isHl('connect') ? ['hl', 'tag-b'] : []"
|
|
:data-tag="tagOf('connect')"
|
|
>
|
|
<span class="d-name">Polar H10</span>
|
|
<span class="conn-status">◉ connected</span>
|
|
</div>
|
|
|
|
<div
|
|
class="charbox"
|
|
:class="isHl('services') ? ['hl', 'tag-b'] : []"
|
|
:data-tag="tagOf('services')"
|
|
>
|
|
<div
|
|
class="char-row"
|
|
:class="isHl('exchange') ? ['hl', 'tag-b'] : []"
|
|
:data-tag="tagOf('exchange')"
|
|
>
|
|
<span class="c-name">Heart Rate</span>
|
|
<span class="c-uuid">0x2A37</span>
|
|
<span class="c-val">72 bpm</span>
|
|
<span class="c-live"><span class="spin">⟳</span> live</span>
|
|
</div>
|
|
<div class="char-row">
|
|
<span class="c-name">Battery</span>
|
|
<span class="c-uuid">0x2A19</span>
|
|
<span class="c-val">88 %</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class="wire"
|
|
:class="isHl('validate') ? ['hl', 'tag-b'] : []"
|
|
:data-tag="tagOf('validate')"
|
|
>
|
|
rx 0x2A37 ← <span class="bytes">06 48</span> ⟶ 72 bpm
|
|
<span class="checked">len-checked</span>
|
|
</div>
|
|
|
|
<div class="btn ghost">disconnect</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- the central's own machinery -->
|
|
<div class="machinery">
|
|
<div class="statebar">
|
|
<span class="badge">permission = {{ frame.perm }}</span>
|
|
<span class="badge">adapter = {{ frame.adapter }}</span>
|
|
<span class="badge">scan = {{ frame.scan }}</span>
|
|
</div>
|
|
<div v-if="frame.link" class="statebar">
|
|
<span class="badge link">link = {{ frame.link }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.specimen {
|
|
--ink: var(--color-base-content);
|
|
--ink-faint: #4f7099;
|
|
--ink-dim: #7ea6cd;
|
|
--amber: var(--color-accent);
|
|
--amber-dim: color-mix(in srgb, var(--color-accent) 14%, transparent);
|
|
--cyan: var(--color-secondary);
|
|
--green: #7fdca0;
|
|
--red: var(--color-error);
|
|
--paper: var(--color-base-100);
|
|
--line: rgba(200, 226, 255, 0.14);
|
|
--line-strong: rgba(200, 226, 255, 0.3);
|
|
font-size: 13px;
|
|
}
|
|
.note {
|
|
color: var(--ink-dim);
|
|
font-size: 11px;
|
|
font-style: italic;
|
|
margin: 0 0 12px;
|
|
}
|
|
|
|
/* device */
|
|
.device {
|
|
max-width: 360px;
|
|
margin: 0 auto;
|
|
border: 1px solid var(--line-strong);
|
|
background: var(--paper);
|
|
}
|
|
.dev-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
padding: 6px 10px;
|
|
border-bottom: 1px solid var(--line);
|
|
}
|
|
.dev-bar .dot {
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
background: var(--ink-faint);
|
|
opacity: 0.5;
|
|
}
|
|
.dev-title {
|
|
margin-left: 8px;
|
|
font-size: 10px;
|
|
letter-spacing: 0.12em;
|
|
text-transform: uppercase;
|
|
color: var(--ink-faint);
|
|
}
|
|
.screen {
|
|
padding: 16px 18px 18px;
|
|
}
|
|
|
|
/* scanning */
|
|
.statusrow {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
margin-bottom: 14px;
|
|
}
|
|
.chip {
|
|
border: 1px solid var(--line-strong);
|
|
color: var(--ink-dim);
|
|
padding: 3px 8px;
|
|
font-size: 11px;
|
|
letter-spacing: 0.03em;
|
|
}
|
|
.chip.on {
|
|
border-color: color-mix(in srgb, var(--green) 55%, transparent);
|
|
color: var(--green);
|
|
}
|
|
.listbox {
|
|
border: 1px solid var(--line);
|
|
}
|
|
.list-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 7px 10px;
|
|
border-bottom: 1px solid var(--line);
|
|
font-size: 12px;
|
|
color: var(--ink);
|
|
}
|
|
.scanning {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
color: var(--amber);
|
|
font-size: 11px;
|
|
}
|
|
.spin {
|
|
display: inline-block;
|
|
}
|
|
.dev-row {
|
|
display: grid;
|
|
grid-template-columns: 1fr auto auto auto;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 8px 10px;
|
|
border-bottom: 1px solid var(--line);
|
|
font-size: 12px;
|
|
}
|
|
.dev-row:last-child {
|
|
border-bottom: none;
|
|
}
|
|
.d-name {
|
|
color: var(--ink);
|
|
}
|
|
.d-addr {
|
|
color: var(--ink-faint);
|
|
font-size: 11px;
|
|
}
|
|
.d-rssi {
|
|
color: var(--ink-dim);
|
|
font-size: 11px;
|
|
}
|
|
.connect-btn {
|
|
border: 1px solid var(--cyan);
|
|
background: color-mix(in srgb, var(--cyan) 16%, transparent);
|
|
color: var(--cyan);
|
|
padding: 2px 8px;
|
|
font-size: 11px;
|
|
}
|
|
|
|
/* connected */
|
|
.conn-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 8px 10px;
|
|
border: 1px solid var(--line);
|
|
margin-bottom: 12px;
|
|
}
|
|
.conn-head .d-name {
|
|
color: var(--ink);
|
|
letter-spacing: 0.03em;
|
|
}
|
|
.conn-status {
|
|
color: var(--green);
|
|
font-size: 11px;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
.charbox {
|
|
border: 1px solid var(--line);
|
|
margin-bottom: 12px;
|
|
}
|
|
.char-row {
|
|
display: grid;
|
|
grid-template-columns: 1fr auto auto auto;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 8px 10px;
|
|
border-bottom: 1px solid var(--line);
|
|
font-size: 12px;
|
|
}
|
|
.char-row:last-child {
|
|
border-bottom: none;
|
|
}
|
|
.c-name {
|
|
color: var(--ink);
|
|
}
|
|
.c-uuid {
|
|
color: var(--ink-faint);
|
|
font-size: 11px;
|
|
}
|
|
.c-val {
|
|
color: var(--cyan);
|
|
}
|
|
.c-live {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
color: var(--amber);
|
|
font-size: 10px;
|
|
}
|
|
.wire {
|
|
border: 1px dashed var(--line-strong);
|
|
padding: 6px 10px;
|
|
margin-bottom: 12px;
|
|
font-size: 11px;
|
|
color: var(--ink-dim);
|
|
}
|
|
.wire .bytes {
|
|
color: var(--amber);
|
|
letter-spacing: 0.1em;
|
|
}
|
|
.wire .checked {
|
|
color: var(--green);
|
|
font-size: 10px;
|
|
margin-left: 4px;
|
|
}
|
|
.btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 8px;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
.btn.ghost {
|
|
background: transparent;
|
|
border: 1px solid var(--line-strong);
|
|
color: var(--ink-faint);
|
|
}
|
|
|
|
/* machinery */
|
|
.machinery {
|
|
max-width: 360px;
|
|
margin: 14px auto 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
.statebar {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
}
|
|
.badge {
|
|
border: 1px solid var(--line-strong);
|
|
color: var(--cyan);
|
|
padding: 2px 8px;
|
|
font-size: 10px;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
.badge.link {
|
|
color: var(--green);
|
|
border-color: color-mix(in srgb, var(--green) 45%, transparent);
|
|
}
|
|
|
|
/* callouts — same convention as the other specimens */
|
|
.hl {
|
|
outline: 1.5px dashed var(--amber);
|
|
outline-offset: 3px;
|
|
position: relative;
|
|
z-index: 2;
|
|
}
|
|
.hl[data-tag]::after {
|
|
content: attr(data-tag);
|
|
position: absolute;
|
|
top: -10px;
|
|
left: 8px;
|
|
background: var(--paper);
|
|
color: var(--amber);
|
|
font-size: 9px;
|
|
padding: 0 5px;
|
|
border: 1px solid var(--amber);
|
|
white-space: nowrap;
|
|
letter-spacing: 0.1em;
|
|
line-height: 1.5;
|
|
z-index: 3;
|
|
}
|
|
.hl.tag-b[data-tag]::after {
|
|
top: auto;
|
|
bottom: -10px;
|
|
}
|
|
</style>
|