# Peripheral Chat Recipe (phone as GATT server)

Canonical write-up for `peripheral-chat.html` / `peripheral-chat.ts`. The iPhone advertises a custom GATT service and accepts writes + emits notifications. A second device (another phone, a laptop, a sensor tag, a Python script with `bleak`) acts as the central.

This recipe is **premium-only** — it requires the WebBLE companion app and is not exposed by any other browser on any OS.

## Preconditions

- **iOS 15.0+** Safari with the WebBLE Safari Web Extension installed **and the WebBLE companion app present** (peripheral mode is relayed through the companion app's `BLEPeripheralServer`, see `Shared (App)/BLEPeripheralServer.swift`).
- Extension enabled under **Settings → Safari → Extensions → WebBLE → Allow**.
- Per-site permission set to **"Always Allow on Every Website"**.
- A second BLE central to talk to: LightBlue / nRF Connect on a phone, `bleak` on desktop, or any BLE-capable sensor.
- The site must be served over HTTPS (companion app's `OriginValidation` rejects `http://` origins).

## Spec citation

- There is no W3C spec for "web acting as GATT peripheral". Chrome/Edge do not expose this surface at all.
- WebBLE's peripheral API is documented in the iOS surface reference: `window.webbleIOS.peripheral`, type `WebBLEPeripheralManager` (`src/webble/api/webble-peripheral.ts`).
- Underlying iOS primitive: `CBPeripheralManager` (Apple Core Bluetooth — [developer.apple.com/documentation/corebluetooth/cbperipheralmanager](https://developer.apple.com/documentation/corebluetooth/cbperipheralmanager)).
- The service and characteristic UUIDs below are **demo UUIDs** — use your own 128-bit random UUIDs in production (generate with `uuidgen` / `crypto.randomUUID()`).

## Exact definition

Service and characteristic registration object passed to `peripheral.addService`:

```js
{
  uuid: '12345678-1234-5678-1234-56789abcdef0',
  characteristics: [{
    uuid: '12345678-1234-5678-1234-56789abcdef1',
    properties: ['read', 'write', 'notify'],
    value: new TextEncoder().encode('hello')
  }]
}
```

## GATT sequence (peripheral side)

1. Feature-detect — `window.webbleIOS` is only defined when the companion app is present.
2. `webbleIOS.peripheral.addService({ uuid, characteristics })` — the companion app registers the service on its `CBPeripheralManager` and returns a `WebBLEPeripheralServiceRecord`.
3. Wire `peripheral.onwriterequest` — fires whenever a subscribed central writes to a characteristic. The `value` field is an `ArrayBuffer`; decode as UTF-8 (or whatever framing you picked).
4. `peripheral.startAdvertising({ localName, serviceUUIDs })` — begins advertising so centrals can discover the device.
5. To push data back: use the peripheral manager's notify/indicate paths once at least one central has subscribed (check `WebBLEPeripheralSubscriptionChange.subscriberCount > 0`).

## Expected output

```
advertising as WebBLE Chat
central wrote: hi from laptop
central wrote: ping
```

## Common errors

| Error (`name` / symptom) | Cause | Remediation |
|---|---|---|
| `TypeError: Cannot read properties of undefined (reading 'peripheral')` | `window.webbleIOS` missing — companion app not installed or site loaded before the extension injected. | Gate behind the `'webbleIOS' in window` check shown in the HTML. Prompt the user to install the companion app. |
| `NotAllowedError` on `startAdvertising` | Companion app lacks Bluetooth permission. | User must grant Bluetooth permission in iOS Settings → WebBLE. |
| `InvalidStateError` on `addService` | Called twice with the same service UUID. | `addService` is idempotent on identical input but rejects conflicting definitions — stop advertising and re-add. |
| `writeRequest` never fires | Central is reading, not writing; or characteristic lacks `write` / `writeWithoutResponse` property. | Confirm the central is issuing a write op; confirm `properties` array on registration. |
| Notifications dropped | Central has not subscribed (no CCCD write). | Have the central call `startNotifications` first; check `onsubscriptionchange` for `subscriberCount`. |

## Verbatim HTML snippet

```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Peripheral chat (phone as GATT server) — WebBLE on iPhone</title>
</head>
<body>
  <aside data-agent-notes>
    <strong>Preconditions</strong>
    <ul>
      <li>iOS 15.0+ Safari with the WebBLE extension <em>and</em> the WebBLE companion app installed.</li>
      <li>Extension enabled: Settings → Safari → Extensions → WebBLE → Allow.</li>
      <li>Per-site permission: "Always Allow on Every Website".</li>
      <li>Site served over HTTPS.</li>
      <li>A second device acting as BLE central (LightBlue, nRF Connect, <code>bleak</code>, etc.).</li>
    </ul>
    <strong>Spec</strong>: No W3C standard — WebBLE premium surface <code>window.webbleIOS.peripheral</code> (<code>WebBLEPeripheralManager</code>). Underlying iOS primitive is <code>CBPeripheralManager</code>. Demo UUIDs below; generate your own with <code>crypto.randomUUID()</code>.
    <p>Canonical write-up: <a href="./peripheral-chat.md">peripheral-chat.md</a>.</p>
  </aside>

  <h1>Peripheral chat — phone as GATT server</h1>
  <button id="start">Start advertising</button>
  <pre id="log" aria-live="polite"></pre>

  <script type="module">
    import '@ios-web-bluetooth/core/auto';

    const CHAT_SERVICE = '12345678-1234-5678-1234-56789abcdef0';
    const CHAT_CHAR    = '12345678-1234-5678-1234-56789abcdef1';

    const log = (line) => {
      const pre = document.getElementById('log');
      pre.textContent += line + '\n';
    };

    document.getElementById('start').addEventListener('click', async () => {
      if (!('webbleIOS' in window)) {
        log('error: peripheral mode requires the WebBLE companion app');
        return;
      }
      try {
        await window.webbleIOS.peripheral.addService({
          uuid: CHAT_SERVICE,
          characteristics: [{
            uuid: CHAT_CHAR,
            properties: ['read', 'write', 'notify'],
            value: new TextEncoder().encode('hello')
          }]
        });

        window.webbleIOS.peripheral.onwriterequest = (ev) => {
          const msg = new TextDecoder().decode(ev.value);
          log(`central wrote: ${msg}`);
        };

        await window.webbleIOS.peripheral.startAdvertising({
          localName: 'WebBLE Chat',
          serviceUUIDs: [CHAT_SERVICE]
        });

        log('advertising as WebBLE Chat');
      } catch (err) {
        log(`error: ${err.name}: ${err.message}`);
      }
    });
  </script>
</body>
</html>
```

## Canonical JS snippet (byte-identical to `docs-src/recipes.md`)

```js
if (!('webbleIOS' in window)) {
  throw new Error('Peripheral mode requires the WebBLE companion app');
}

const CHAT_SERVICE = '12345678-1234-5678-1234-56789abcdef0';
const CHAT_CHAR    = '12345678-1234-5678-1234-56789abcdef1';

const record = await window.webbleIOS.peripheral.addService({
  uuid: CHAT_SERVICE,
  characteristics: [{
    uuid: CHAT_CHAR,
    properties: ['read', 'write', 'notify'],
    value: new TextEncoder().encode('hello')
  }]
});

window.webbleIOS.peripheral.onwriterequest = (ev) => {
  const msg = new TextDecoder().decode(ev.value);
  console.log('central wrote:', msg);
};

await window.webbleIOS.peripheral.startAdvertising({
  localName: 'WebBLE Chat',
  serviceUUIDs: [CHAT_SERVICE]
});
```

This block is reproduced byte-for-byte from `website-src/docs-src/recipes.md` §"Peripheral chat" so agents indexing either source get the same answer. The HTML snippet above wraps the same logic in a click handler with an on-page log instead of `console.log`, but the API surface is identical.
