Compare commits
2 Commits
7a60e57f46
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
792b4f1b17 | ||
|
|
903e5465d5 |
@@ -164,15 +164,22 @@ const funcCount = computed(() => props.blueprint.functions.length)
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- COMPANION 2: state machine -->
|
<!-- COMPANION 2: state machine(s) -->
|
||||||
<template v-if="blueprint.stateMachine">
|
<template v-if="blueprint.stateMachines?.length">
|
||||||
<h2 class="section">Lifecycle — state machine</h2>
|
<h2 class="section">
|
||||||
<div class="panel">
|
Lifecycle — state machine{{ blueprint.stateMachines.length > 1 ? "s" : "" }}
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
v-for="(m, i) in blueprint.stateMachines"
|
||||||
|
:key="i"
|
||||||
|
class="panel"
|
||||||
|
:class="{ stacked: i > 0 }"
|
||||||
|
>
|
||||||
<div class="cap">
|
<div class="cap">
|
||||||
<span>{{ blueprint.stateMachine?.caption ?? "Composed lifecycle" }}</span
|
<span>{{ m.caption ?? "Composed lifecycle" }}</span
|
||||||
><span class="id">{{ blueprint.stateMachine?.field ?? "loadState : LoadState" }}</span>
|
><span class="id">{{ m.field ?? "loadState : LoadState" }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="body svg-wrap"><StateMachine :machine="blueprint.stateMachine" /></div>
|
<div class="body svg-wrap"><StateMachine :machine="m" /></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -398,6 +405,10 @@ code {
|
|||||||
.svg-wrap {
|
.svg-wrap {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
/* stacked state-machine panels (a blueprint with more than one lifecycle) */
|
||||||
|
.panel.stacked {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
/* full-view readout */
|
/* full-view readout */
|
||||||
.rblock {
|
.rblock {
|
||||||
|
|||||||
@@ -46,6 +46,26 @@ const edges = computed(() =>
|
|||||||
props.machine.edges.map((e) => {
|
props.machine.edges.map((e) => {
|
||||||
const a = byId.value[e.from]
|
const a = byId.value[e.from]
|
||||||
const b = byId.value[e.to]
|
const b = byId.value[e.to]
|
||||||
|
// Self-loop (a === b): a small arc off the top edge of the node, arrowhead
|
||||||
|
// curling back in. Used by the recurring transitions that don't change state
|
||||||
|
// — BLE's discover(p) on Scanning and read/write/subscribe on Ready.
|
||||||
|
if (a === b) {
|
||||||
|
const cx = a.x + a.w / 2
|
||||||
|
const top = a.y
|
||||||
|
const r = Math.min(a.w / 3.2, 20)
|
||||||
|
const h = 30
|
||||||
|
const sx = cx - r
|
||||||
|
const tx = cx + r
|
||||||
|
return {
|
||||||
|
key: `${e.from}-${e.to}-${e.label}`,
|
||||||
|
d: `M${sx} ${top} C ${sx} ${top - h} ${tx} ${top - h} ${tx} ${top}`,
|
||||||
|
color: SM_COLORS[e.kind],
|
||||||
|
marker: `url(#ar-${e.kind})`,
|
||||||
|
label: e.label,
|
||||||
|
lx: cx,
|
||||||
|
ly: top - h - 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
const [acx, acy] = center(a)
|
const [acx, acy] = center(a)
|
||||||
const [bcx, bcy] = center(b)
|
const [bcx, bcy] = center(b)
|
||||||
const [sx, sy] = edgePoint(a, bcx, bcy)
|
const [sx, sy] = edgePoint(a, bcx, bcy)
|
||||||
@@ -92,6 +112,15 @@ const KIND_LABELS: Record<SmKind, string> = {
|
|||||||
signout: "signOut()",
|
signout: "signOut()",
|
||||||
reset: "reset()",
|
reset: "reset()",
|
||||||
unlock: "unlock()",
|
unlock: "unlock()",
|
||||||
|
powerOn: "powerOn()",
|
||||||
|
powerOff: "powerOff()",
|
||||||
|
startScan: "startScan()",
|
||||||
|
stopScan: "stopScan()",
|
||||||
|
discover: "discover(p)",
|
||||||
|
connect: "connect()",
|
||||||
|
discoverServices: "discoverServices()",
|
||||||
|
exchange: "read / write / subscribe",
|
||||||
|
disconnect: "disconnect() / drop",
|
||||||
}
|
}
|
||||||
const KIND_ORDER: SmKind[] = [
|
const KIND_ORDER: SmKind[] = [
|
||||||
"submit",
|
"submit",
|
||||||
@@ -111,6 +140,15 @@ const KIND_ORDER: SmKind[] = [
|
|||||||
"revoke",
|
"revoke",
|
||||||
"expire",
|
"expire",
|
||||||
"clear",
|
"clear",
|
||||||
|
"powerOn",
|
||||||
|
"powerOff",
|
||||||
|
"startScan",
|
||||||
|
"stopScan",
|
||||||
|
"discover",
|
||||||
|
"connect",
|
||||||
|
"discoverServices",
|
||||||
|
"exchange",
|
||||||
|
"disconnect",
|
||||||
]
|
]
|
||||||
|
|
||||||
// Lay the legend out left-to-right, wrapping to a new row when it would overflow
|
// Lay the legend out left-to-right, wrapping to a new row when it would overflow
|
||||||
|
|||||||
388
src/data/bleBlueprint.ts
Normal file
388
src/data/bleBlueprint.ts
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
// The BLE blueprint, as data. Transcribed by hand from the ontology `ble`
|
||||||
|
// README + ble.als. A *feature* — a device acting as a Bluetooth Low Energy
|
||||||
|
// *central*: it scans for advertising peripherals, connects to one, and
|
||||||
|
// reads/writes/subscribes to its GATT characteristics over a link that may drop
|
||||||
|
// at any time. It composes the Permission capability, which owns the consent
|
||||||
|
// lifecycle that gates startScan().
|
||||||
|
//
|
||||||
|
// BLE has TWO independent lifecycles, so it renders two state machines:
|
||||||
|
// 1. adapter + scan (central-level) — the radio power and the scan loop,
|
||||||
|
// doubly gated by Permission + a powered-on radio;
|
||||||
|
// 2. per-peripheral link — Disconnected → Connecting → Connected → Ready,
|
||||||
|
// with drop/disconnect returns and on-Ready GATT exchange.
|
||||||
|
|
||||||
|
import type { Blueprint, StateMachine } from "./blueprint"
|
||||||
|
|
||||||
|
// ── Machine 1 · adapter + scan (central) ───────────────────────────────────
|
||||||
|
// The README nests ScanIdle/Scanning inside a PoweredOn superstate; the flat
|
||||||
|
// diagram unfolds that superstate into its two substates. powerOn() enters at
|
||||||
|
// ScanIdle; powerOff() forces the central dormant from either substate (two
|
||||||
|
// edges back to PoweredOff). discover(p) is the OS callback that accumulates
|
||||||
|
// peripherals while Scanning — a self-loop.
|
||||||
|
const BLE_ADAPTER_MACHINE: StateMachine = {
|
||||||
|
initId: "poweredoff",
|
||||||
|
caption: "Adapter + scan · central",
|
||||||
|
field: "adapter : AdapterState · scan : ScanState",
|
||||||
|
nodes: [
|
||||||
|
{ id: "poweredoff", x: 60, y: 150, w: 130, h: 44, label: "PoweredOff" },
|
||||||
|
{ id: "scanidle", x: 360, y: 150, w: 120, h: 44, label: "ScanIdle" },
|
||||||
|
{ id: "scanning", x: 660, y: 150, w: 120, h: 44, label: "Scanning" },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
// forward path (arcs up, labels above the row)
|
||||||
|
{ from: "poweredoff", to: "scanidle", label: "powerOn()", kind: "powerOn", off: -34 },
|
||||||
|
{
|
||||||
|
from: "scanidle",
|
||||||
|
to: "scanning",
|
||||||
|
label: "startScan() [granted]",
|
||||||
|
kind: "startScan",
|
||||||
|
off: -34,
|
||||||
|
},
|
||||||
|
// recurring OS callback while scanning
|
||||||
|
{ from: "scanning", to: "scanning", label: "discover(p)", kind: "discover", off: 0 },
|
||||||
|
// returns (arc below the row; deeper = wider span)
|
||||||
|
{ from: "scanning", to: "scanidle", label: "stopScan()", kind: "stopScan", off: -55 },
|
||||||
|
{ from: "scanidle", to: "poweredoff", label: "powerOff()", kind: "powerOff", off: -95 },
|
||||||
|
{ from: "scanning", to: "poweredoff", label: "powerOff()", kind: "powerOff", off: -145 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Machine 2 · per-peripheral link ────────────────────────────────────────
|
||||||
|
// Disconnected → Connecting → Connected → Ready. established()/connectFail() are
|
||||||
|
// the two OS resolutions of a connect() attempt. On Ready, read/write/subscribe
|
||||||
|
// leave the state unchanged (self-loop). disconnect()/drop returns to
|
||||||
|
// Disconnected from either Connected or Ready, invalidating cached handles.
|
||||||
|
const BLE_LINK_MACHINE: StateMachine = {
|
||||||
|
initId: "disconnected",
|
||||||
|
caption: "Link lifecycle · per peripheral",
|
||||||
|
field: "peripheral.link : LinkState",
|
||||||
|
nodes: [
|
||||||
|
{ id: "disconnected", x: 40, y: 150, w: 140, h: 44, label: "Disconnected" },
|
||||||
|
{ id: "connecting", x: 290, y: 150, w: 125, h: 44, label: "Connecting" },
|
||||||
|
{ id: "connected", x: 525, y: 150, w: 125, h: 44, label: "Connected" },
|
||||||
|
{ id: "ready", x: 760, y: 150, w: 95, h: 44, label: "Ready" },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
// forward path (arcs up, labels above the row)
|
||||||
|
{ from: "disconnected", to: "connecting", label: "connect()", kind: "connect", off: -34 },
|
||||||
|
{ from: "connecting", to: "connected", label: "established()", kind: "succeed", off: -34 },
|
||||||
|
{
|
||||||
|
from: "connected",
|
||||||
|
to: "ready",
|
||||||
|
label: "discoverServices()",
|
||||||
|
kind: "discoverServices",
|
||||||
|
off: -34,
|
||||||
|
},
|
||||||
|
// on-Ready GATT operations — state unchanged (self-loop)
|
||||||
|
{ from: "ready", to: "ready", label: "read / write / subscribe", kind: "exchange", off: 0 },
|
||||||
|
// returns to Disconnected (arc below the row; deeper = wider span)
|
||||||
|
{ from: "connecting", to: "disconnected", label: "connectFail()", kind: "fail", off: -55 },
|
||||||
|
{ from: "connected", to: "disconnected", label: "disconnect()", kind: "disconnect", off: -100 },
|
||||||
|
{
|
||||||
|
from: "ready",
|
||||||
|
to: "disconnected",
|
||||||
|
label: "disconnect() / drop",
|
||||||
|
kind: "disconnect",
|
||||||
|
off: -150,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ble: Blueprint = {
|
||||||
|
slug: "ble",
|
||||||
|
name: "BLE",
|
||||||
|
tagline:
|
||||||
|
"A device acting as a Bluetooth Low Energy central — scan for advertising peripherals, connect to one, and read/write/subscribe to its GATT characteristics over a link that may drop at any time.",
|
||||||
|
role: "feature",
|
||||||
|
composesCount: 1,
|
||||||
|
|
||||||
|
sig: {
|
||||||
|
header: "Central",
|
||||||
|
fields: [
|
||||||
|
{ name: "permission", type: "one Permission", note: "consent state (owned by Permission)" },
|
||||||
|
{ name: "adapter", type: "one AdapterState", note: "radio power: PoweredOff | PoweredOn" },
|
||||||
|
{ name: "scan", type: "one ScanState", note: "ScanIdle | Scanning" },
|
||||||
|
{ name: "discovered", type: "set Peripheral", note: "peripherals seen while scanning" },
|
||||||
|
],
|
||||||
|
types: [
|
||||||
|
"AdapterState = PoweredOff | PoweredOn",
|
||||||
|
"ScanState = ScanIdle | Scanning",
|
||||||
|
"LinkState = Disconnected | Connecting | Connected | Ready",
|
||||||
|
"Peripheral { link : LinkState, characteristics, subscriptions : set Characteristic }",
|
||||||
|
"scan = Scanning ⟹ permission = Granted ∧ adapter = PoweredOn (doubly gated)",
|
||||||
|
"some p.characteristics ⟹ p.link = Ready",
|
||||||
|
"subscriptions ⊆ characteristics",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
functions: [
|
||||||
|
{
|
||||||
|
id: "permission",
|
||||||
|
name: "Permission",
|
||||||
|
fig: "01",
|
||||||
|
caps: ["Permission"],
|
||||||
|
verb: "Hold the single consent status and gate startScan() behind it; delegate request/grant/deny to the Permission capability.",
|
||||||
|
state: [["permission.status", "PermissionStatus"]],
|
||||||
|
behaviors: [
|
||||||
|
{
|
||||||
|
sig: "requestPermission()",
|
||||||
|
pre: "permission.status = NotAsked",
|
||||||
|
eff: "delegates to Permission.request; rest of Central preserved",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sig: "grant()",
|
||||||
|
pre: "permission.status = Requested",
|
||||||
|
eff: "delegates to Permission.grant; rest of Central preserved",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sig: "deny()",
|
||||||
|
pre: "permission.status = Requested",
|
||||||
|
eff: "delegates to Permission.deny; scan ← ScanIdle",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
invariants: [
|
||||||
|
"scan = Scanning ⟹ permission.status = Granted — consent is one of the two scan gates",
|
||||||
|
"Granted and Denied are terminal from the capability's view — the app cannot re-prompt the OS dialog",
|
||||||
|
],
|
||||||
|
failures: [
|
||||||
|
"Location coupling — Android < 12 requires ACCESS_FINE_LOCATION to scan; a confusing permission prompt blocks scanning entirely if location is denied",
|
||||||
|
],
|
||||||
|
perf: [],
|
||||||
|
notes: [
|
||||||
|
"The three permission behaviors compose Permission's transitions with a frame condition on the rest of Central. On Web, navigator.bluetooth.requestDevice() has the browser render the chooser — silent enumeration is impossible.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "power",
|
||||||
|
name: "Power",
|
||||||
|
fig: "02",
|
||||||
|
caps: ["BLE"],
|
||||||
|
verb: "Track the adapter's on/off state; force the central fully dormant the moment the radio powers off.",
|
||||||
|
state: [
|
||||||
|
["adapter", "AdapterState"],
|
||||||
|
["scan", "ScanState"],
|
||||||
|
],
|
||||||
|
behaviors: [
|
||||||
|
{
|
||||||
|
sig: "powerOn()",
|
||||||
|
pre: "adapter = PoweredOff",
|
||||||
|
eff: "adapter ← PoweredOn; scan ← ScanIdle",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sig: "powerOff()",
|
||||||
|
pre: "adapter = PoweredOn",
|
||||||
|
eff: "adapter ← PoweredOff; scan ← ScanIdle; discovered ← ∅",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
invariants: [
|
||||||
|
"adapter = PoweredOff ⟹ scan = ScanIdle ∧ every known peripheral is Disconnected — a dead radio is fully dormant",
|
||||||
|
"scan = Scanning ⟹ adapter = PoweredOn — a live radio is the second scan gate",
|
||||||
|
],
|
||||||
|
failures: [
|
||||||
|
"Adapter off mid-session — the user toggles Bluetooth off in Control Center / Settings; every link drops and subscriptions are lost, so the app must observe adapter state and clean up",
|
||||||
|
],
|
||||||
|
perf: [],
|
||||||
|
notes: [
|
||||||
|
"powerOn/powerOff are OS/user events, not app calls — the app reacts to them. Losing the radio is modelled as the central collapsing to its dormant state.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "scan",
|
||||||
|
name: "Scan",
|
||||||
|
fig: "03",
|
||||||
|
caps: ["BLE", "Permission"],
|
||||||
|
verb: "Discover advertising peripherals once doubly gated (Granted + PoweredOn) and accumulate them into the discovered set.",
|
||||||
|
state: [
|
||||||
|
["scan", "ScanState"],
|
||||||
|
["discovered", "set Peripheral"],
|
||||||
|
],
|
||||||
|
behaviors: [
|
||||||
|
{
|
||||||
|
sig: "startScan()",
|
||||||
|
pre: "permission = Granted; adapter = PoweredOn; scan = ScanIdle",
|
||||||
|
eff: "scan ← Scanning",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sig: "discover(p)",
|
||||||
|
pre: "scan = Scanning",
|
||||||
|
eff: "discovered ← discovered ∪ {p}",
|
||||||
|
},
|
||||||
|
{ sig: "stopScan()", pre: "scan = Scanning", eff: "scan ← ScanIdle" },
|
||||||
|
],
|
||||||
|
invariants: [
|
||||||
|
"scan = Scanning ⟹ permission = Granted ∧ adapter = PoweredOn — scanning is doubly gated",
|
||||||
|
],
|
||||||
|
failures: [
|
||||||
|
"Scan drain — startScan() never paired with stopScan(); a continuous radio scan drains the battery and Android throttles or drops results",
|
||||||
|
],
|
||||||
|
perf: [
|
||||||
|
"First advertisement — a discovered peripheral surfaces within ~1 s of startScan() under normal RF conditions",
|
||||||
|
],
|
||||||
|
notes: [
|
||||||
|
"discover(p) is an OS callback fired repeatedly while Scanning — a self-loop that grows the discovered set without changing state.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "connect",
|
||||||
|
name: "Connect",
|
||||||
|
fig: "04",
|
||||||
|
caps: ["BLE", "Peripheral"],
|
||||||
|
verb: "Establish the link to a chosen peripheral and tear it down; resolve both the successful connection and involuntary drops.",
|
||||||
|
state: [["peripheral.link", "LinkState"]],
|
||||||
|
behaviors: [
|
||||||
|
{ sig: "connect(p)", pre: "p.link = Disconnected", eff: "p.link ← Connecting" },
|
||||||
|
{ sig: "established(p)", pre: "p.link = Connecting", eff: "p.link ← Connected" },
|
||||||
|
{ sig: "connectFail(p)", pre: "p.link = Connecting", eff: "p.link ← Disconnected" },
|
||||||
|
{
|
||||||
|
sig: "disconnect(p)",
|
||||||
|
pre: "p.link ≠ Disconnected",
|
||||||
|
eff: "p.link ← Disconnected; characteristics, subscriptions ← ∅",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
invariants: [
|
||||||
|
"p.link = Disconnected ⟹ no p.characteristics ∧ no p.subscriptions — a dropped link invalidates every cached handle",
|
||||||
|
],
|
||||||
|
failures: [
|
||||||
|
"Silent disconnect — the peripheral moves out of range or its battery dies; link → Disconnected with no app-initiated call and cached handles go stale",
|
||||||
|
],
|
||||||
|
perf: [
|
||||||
|
"Connection establishment — Connecting → Connected within ~2–3 s of connect() (bounded by the connection interval)",
|
||||||
|
],
|
||||||
|
notes: [
|
||||||
|
"disconnect(p) covers both an app-initiated teardown and an involuntary drop (out of range, peripheral power-off) — either way, cached handles are invalidated.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "discover-services",
|
||||||
|
name: "Discover",
|
||||||
|
fig: "05",
|
||||||
|
caps: ["Peripheral", "Characteristic"],
|
||||||
|
verb: "Enumerate the peripheral's GATT services and characteristics once the link is Connected, promoting it to Ready.",
|
||||||
|
state: [
|
||||||
|
["peripheral.link", "LinkState"],
|
||||||
|
["peripheral.characteristics", "set Characteristic"],
|
||||||
|
],
|
||||||
|
behaviors: [
|
||||||
|
{
|
||||||
|
sig: "discoverServices(p, cs)",
|
||||||
|
pre: "p.link = Connected",
|
||||||
|
eff: "p.link ← Ready; p.characteristics ← cs",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
invariants: [
|
||||||
|
"some p.characteristics ⟹ p.link = Ready — GATT handles exist only after service discovery completes",
|
||||||
|
],
|
||||||
|
failures: [
|
||||||
|
"Stale handle reuse — reconnect and reuse cached handles without re-discovering; reads/writes target invalid handles and return GATT errors (invariant #5 exists to prevent this)",
|
||||||
|
],
|
||||||
|
perf: [],
|
||||||
|
notes: [
|
||||||
|
"Ready is the only state in which characteristic handles are valid; a drop clears them, so handles must be re-discovered after every reconnect.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "exchange",
|
||||||
|
name: "Exchange",
|
||||||
|
fig: "06",
|
||||||
|
caps: ["Peripheral", "Characteristic"],
|
||||||
|
verb: "Read, write, and subscribe to characteristic values over a Ready link, against discovered characteristics only.",
|
||||||
|
state: [
|
||||||
|
["peripheral.characteristics", "set Characteristic"],
|
||||||
|
["peripheral.subscriptions", "set Characteristic"],
|
||||||
|
],
|
||||||
|
behaviors: [
|
||||||
|
{
|
||||||
|
sig: "subscribe(p, ch)",
|
||||||
|
pre: "p.link = Ready; ch ∈ p.characteristics",
|
||||||
|
eff: "p.subscriptions ← p.subscriptions ∪ {ch}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sig: "unsubscribe(p, ch)",
|
||||||
|
pre: "p.link = Ready",
|
||||||
|
eff: "p.subscriptions ← p.subscriptions ∖ {ch}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sig: "read/write(p, ch)",
|
||||||
|
pre: "p.link = Ready; ch ∈ p.characteristics",
|
||||||
|
eff: "one-shot GATT op; no link/subscription change",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
invariants: [
|
||||||
|
"p.subscriptions ⊆ p.characteristics — cannot subscribe to a characteristic that was never discovered",
|
||||||
|
"reads/writes are permitted only against a Ready peripheral's discovered characteristics",
|
||||||
|
],
|
||||||
|
failures: [
|
||||||
|
"Encryption mismatch — an encrypted characteristic is accessed without a completed bond; the op fails with a GATT insufficient-authentication error until pairing completes",
|
||||||
|
],
|
||||||
|
perf: [
|
||||||
|
"Notification latency — subscribed-characteristic updates delivered within the negotiated connection interval (7.5 ms – 4 s)",
|
||||||
|
"MTU negotiation — completed before the first large read/write, or payloads truncate to the 23-byte default",
|
||||||
|
],
|
||||||
|
notes: [
|
||||||
|
"read/write/subscribe leave the link in Ready — a self-loop. They are the recurring work of a connected session.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "validate",
|
||||||
|
name: "Validate",
|
||||||
|
fig: "07",
|
||||||
|
caps: ["BLE"],
|
||||||
|
verb: "Treat every peripheral payload as untrusted attacker-controllable input; bounds-check before parsing and never forward raw device identity.",
|
||||||
|
state: [["peripheral.characteristics", "set Characteristic"]],
|
||||||
|
behaviors: [],
|
||||||
|
invariants: [
|
||||||
|
"the peripheral is an untrusted external device — its payloads are validated on receipt, never parsed as trusted",
|
||||||
|
"raw MAC, unparsed characteristic bytes, and the peripheral display name never cross to the app's own backend — only derived, validated domain values do",
|
||||||
|
],
|
||||||
|
failures: [
|
||||||
|
"Untrusted payload — a malformed characteristic value from a rogue or buggy peripheral crashes or overflows the parser unless its length is validated on receipt",
|
||||||
|
],
|
||||||
|
perf: [],
|
||||||
|
notes: [
|
||||||
|
"Telemetry defence in depth: peripheral name (PII), raw MAC (a tracking vector), and characteristic values (health/biometric data) must never appear in analytics events — only coarse counters and standard-service UUIDs.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
coreInvariants: [
|
||||||
|
"scan = Scanning ⟹ permission.status = Granted ∧ adapter = PoweredOn — scanning requires both consent and a live radio (two independent gates)",
|
||||||
|
"adapter = PoweredOff ⟹ scan = ScanIdle ∧ every discovered peripheral is Disconnected — a dead radio is fully dormant",
|
||||||
|
"some p.characteristics ⟹ p.link = Ready — GATT characteristic handles exist only after service discovery completes",
|
||||||
|
"p.subscriptions ⊆ p.characteristics — cannot subscribe to a characteristic that was never discovered",
|
||||||
|
"p.link = Disconnected ⟹ no p.characteristics ∧ no p.subscriptions — a dropped link invalidates every cached handle and subscription",
|
||||||
|
],
|
||||||
|
|
||||||
|
composition: {
|
||||||
|
caps: [
|
||||||
|
{ id: "core", label: "Central", sub: "adapter, scan, discovered", core: true },
|
||||||
|
{ id: "permission", label: "Permission", sub: "status (consent)" },
|
||||||
|
{ id: "peripheral", label: "Peripheral", sub: "link, characteristics" },
|
||||||
|
{ id: "gatt", label: "Characteristic", sub: "GATT value handle" },
|
||||||
|
],
|
||||||
|
funcs: ["Permission", "Power", "Scan", "Connect", "Discover", "Exchange", "Validate"],
|
||||||
|
links: [
|
||||||
|
["permission", "Permission"],
|
||||||
|
["core", "Permission"],
|
||||||
|
["core", "Power"],
|
||||||
|
["core", "Scan"],
|
||||||
|
["permission", "Scan"],
|
||||||
|
["peripheral", "Scan"],
|
||||||
|
["core", "Connect"],
|
||||||
|
["peripheral", "Connect"],
|
||||||
|
["peripheral", "Discover"],
|
||||||
|
["gatt", "Discover"],
|
||||||
|
["peripheral", "Exchange"],
|
||||||
|
["gatt", "Exchange"],
|
||||||
|
["core", "Validate"],
|
||||||
|
["gatt", "Validate"],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
stateMachines: [BLE_ADAPTER_MACHINE, BLE_LINK_MACHINE],
|
||||||
|
|
||||||
|
related: [
|
||||||
|
{ name: "Permission", relation: "composes · gates startScan" },
|
||||||
|
{ name: "Geolocation", relation: "sibling · permission-gated" },
|
||||||
|
{ name: "PushNotification", relation: "sibling · permission-gated" },
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -78,6 +78,17 @@ export type SmKind =
|
|||||||
| "signout"
|
| "signout"
|
||||||
| "reset"
|
| "reset"
|
||||||
| "unlock"
|
| "unlock"
|
||||||
|
// BLE adapter + scan lifecycle (central)
|
||||||
|
| "powerOn"
|
||||||
|
| "powerOff"
|
||||||
|
| "startScan"
|
||||||
|
| "stopScan"
|
||||||
|
| "discover"
|
||||||
|
// BLE per-peripheral link lifecycle
|
||||||
|
| "connect"
|
||||||
|
| "discoverServices"
|
||||||
|
| "exchange"
|
||||||
|
| "disconnect"
|
||||||
|
|
||||||
export interface SmEdge {
|
export interface SmEdge {
|
||||||
from: string
|
from: string
|
||||||
@@ -119,6 +130,17 @@ export const SM_COLORS: Record<SmKind | "init", string> = {
|
|||||||
signout: "#b79cff",
|
signout: "#b79cff",
|
||||||
reset: "#67d0c0",
|
reset: "#67d0c0",
|
||||||
unlock: "#4fb3a6",
|
unlock: "#4fb3a6",
|
||||||
|
// BLE adapter+scan: green power-on / magenta power-off / blue scan / purple stop / teal discover
|
||||||
|
powerOn: "#7fdca0",
|
||||||
|
powerOff: "#ff5c8a",
|
||||||
|
startScan: "#8fd0ff",
|
||||||
|
stopScan: "#b79cff",
|
||||||
|
discover: "#67d0c0",
|
||||||
|
// BLE link: blue connect / amber service-discovery / teal exchange / magenta drop
|
||||||
|
connect: "#8fd0ff",
|
||||||
|
discoverServices: "#ffb84d",
|
||||||
|
exchange: "#67d0c0",
|
||||||
|
disconnect: "#ff5c8a",
|
||||||
init: "#4f7099",
|
init: "#4f7099",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +166,9 @@ export interface Blueprint {
|
|||||||
functions: BlueprintFunction[]
|
functions: BlueprintFunction[]
|
||||||
coreInvariants: string[]
|
coreInvariants: string[]
|
||||||
composition?: Composition
|
composition?: Composition
|
||||||
stateMachine?: StateMachine
|
// Zero or more lifecycles. Most blueprints have one (or reuse LOADSTATE_MACHINE);
|
||||||
|
// some — like BLE, which is a central with an adapter/scan lifecycle *and* a
|
||||||
|
// per-peripheral link lifecycle — render several, each in its own panel.
|
||||||
|
stateMachines?: StateMachine[]
|
||||||
related?: RelatedRef[]
|
related?: RelatedRef[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ import { grid } from "./gridBlueprint"
|
|||||||
import { calendar } from "./calendarBlueprint"
|
import { calendar } from "./calendarBlueprint"
|
||||||
import { rememberMe } from "./rememberMeBlueprint"
|
import { rememberMe } from "./rememberMeBlueprint"
|
||||||
import { passwordAuth } from "./passwordAuthBlueprint"
|
import { passwordAuth } from "./passwordAuthBlueprint"
|
||||||
|
import { ble } from "./bleBlueprint"
|
||||||
import ListSpecimen from "@/specimens/ListSpecimen.vue"
|
import ListSpecimen from "@/specimens/ListSpecimen.vue"
|
||||||
import GridSpecimen from "@/specimens/GridSpecimen.vue"
|
import GridSpecimen from "@/specimens/GridSpecimen.vue"
|
||||||
import CalendarSpecimen from "@/specimens/CalendarSpecimen.vue"
|
import CalendarSpecimen from "@/specimens/CalendarSpecimen.vue"
|
||||||
import RememberMeSpecimen from "@/specimens/RememberMeSpecimen.vue"
|
import RememberMeSpecimen from "@/specimens/RememberMeSpecimen.vue"
|
||||||
import PasswordAuthSpecimen from "@/specimens/PasswordAuthSpecimen.vue"
|
import PasswordAuthSpecimen from "@/specimens/PasswordAuthSpecimen.vue"
|
||||||
|
import BleSpecimen from "@/specimens/BleSpecimen.vue"
|
||||||
|
|
||||||
export interface RegistryEntry {
|
export interface RegistryEntry {
|
||||||
blueprint: Blueprint
|
blueprint: Blueprint
|
||||||
@@ -24,6 +26,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
|||||||
list: { blueprint: list, specimen: ListSpecimen },
|
list: { blueprint: list, specimen: ListSpecimen },
|
||||||
grid: { blueprint: grid, specimen: GridSpecimen },
|
grid: { blueprint: grid, specimen: GridSpecimen },
|
||||||
calendar: { blueprint: calendar, specimen: CalendarSpecimen },
|
calendar: { blueprint: calendar, specimen: CalendarSpecimen },
|
||||||
|
ble: { blueprint: ble, specimen: BleSpecimen },
|
||||||
"password-auth": { blueprint: passwordAuth, specimen: PasswordAuthSpecimen },
|
"password-auth": { blueprint: passwordAuth, specimen: PasswordAuthSpecimen },
|
||||||
"remember-me": { blueprint: rememberMe, specimen: RememberMeSpecimen },
|
"remember-me": { blueprint: rememberMe, specimen: RememberMeSpecimen },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,5 +204,5 @@ export const calendar: Blueprint = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
stateMachine: CALENDAR_MACHINE,
|
stateMachines: [CALENDAR_MACHINE],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,5 +202,5 @@ export const grid: Blueprint = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
stateMachine: LOADSTATE_MACHINE,
|
stateMachines: [LOADSTATE_MACHINE],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -226,5 +226,5 @@ export const list: Blueprint = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
stateMachine: LOADSTATE_MACHINE,
|
stateMachines: [LOADSTATE_MACHINE],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -333,7 +333,7 @@ export const passwordAuth: Blueprint = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
stateMachine: PASSWORD_AUTH_MACHINE,
|
stateMachines: [PASSWORD_AUTH_MACHINE],
|
||||||
|
|
||||||
related: [
|
related: [
|
||||||
{ name: "Form", relation: "extends · base" },
|
{ name: "Form", relation: "extends · base" },
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ export const rememberMe: Blueprint = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
stateMachine: REMEMBER_ME_MACHINE,
|
stateMachines: [REMEMBER_ME_MACHINE],
|
||||||
|
|
||||||
related: [
|
related: [
|
||||||
{ name: "PasswordAuth", relation: "host · requires" },
|
{ name: "PasswordAuth", relation: "host · requires" },
|
||||||
|
|||||||
481
src/specimens/BleSpecimen.vue
Normal file
481
src/specimens/BleSpecimen.vue
Normal file
@@ -0,0 +1,481 @@
|
|||||||
|
<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>
|
||||||
Reference in New Issue
Block a user