# Recipe: USB event monitor with role-interfaces

Subscribe to `hw:/usb/**` events and drive a UI, a CLI, or a service
— picking the loop shape (`monitor_fd()` / `monitor()` / `amonitor()`)
that matches your consumer's event loop. All three subscribe to the
same underlying shm ring; pick one, the broker behaves identically.

This is the pattern for: a desktop USB-device monitor, a CLI flicker
diagnostic, a recovery tool watching for a device to appear in DFU
mode, a server tracking hot-plug for inventory.

> # ⚠️ Future shape — Tier-1, not yet wired
>
> **The entire surface this recipe documents — `Client.monitor()`,
> `Client.amonitor()`, `Client.monitor_fd()`, `client.drain_events()`,
> `Event`/`ScriptedBus`/`FakeResource` — is reserved in the public API
> but raises `NotImplementedError` today.** The broker-side subscribe
> protocol is the next hardening pass after the Tier-0 survivability
> work (Part 12 §3 / `hrb-design/mgf-hrb-survivability-review.md`).
> `examples/README.md:53` carries the same caveat.
>
> The recipe is kept in this Tier-1 form so consumers can architect
> against it now (choosing loop shape, planning role-interface scope)
> and so the design's three-layer event API stays documented end-to-
> end. When the wiring lands, this banner is removed and the testing
> section flips from "pseudocode against not-yet-shipped fakes" to
> runnable.
>
> **What you can do today:** poll `client.list(scope)` on a coarse
> interval; for one-shot "wait for X to appear" use cases, that loop
> plus the identity comparator in
> [`track-a-device-across-a-mode-change.md`](track-a-device-across-a-mode-change.md)
> handles the iFig-DFU case the §8 example targets.

---

## §1. Pick your loop

| You are writing… | Use | Why |
|---|---|---|
| A PySide6 / Qt GUI | `monitor_fd()` + `QSocketNotifier` | Qt's main loop owns the fd; events fire on the main thread without a worker. |
| A blocking CLI / a worker thread | `monitor()` (sync iterator) | Natural Python; the thread blocks on the iterator. |
| An asyncio service | `amonitor()` (async iterator) | The asyncio loop owns the wait; no thread pool needed. |

Three layers, one underlying stream. The broker emits events to a
shm ring; the SDK wraps that ring three ways to fit each loop.

---

## §2. The Observer role-interface

The SDK's role-interfaces (Part 8.2) decompose by ISP. A monitor
needs only the **`Observer`** role:

```python
class Observer(Protocol):
    def list(self, urn_glob: str) -> tuple[Resource, ...]: ...
    def inspect(self, urn: str) -> Resource: ...
    # subscribe* methods are part of Observer because observing
    # contends with nothing (Part 3.3) — every consumer can observe.
```

You can hold an `{observe}`-only capability (the smallest, safest
right) and **monitor anything in your scope**, while being structurally
incapable of `claim` or `raw`. Type-shape and security agree —
defense in depth at the API surface (Part 8.2).

---

## §3. Synchronous monitor (CLI / threaded worker)

```python
# Future shape — Tier-1 stub today; see banner at top.
from mgf.hrb import Client
from mgf.hrb.domain import Event   # `Event` is a future addition to mgf.hrb.domain

client = Client.connect("/tmp/mgf-hrb.sock", cap_id=my_observe_cap_id)

# Optional: take a snapshot first so the monitor starts from a known
# state (otherwise the first event arrives at whatever the bus does
# next, which may be never).
initial = client.list("hw:/usb/**")
render_initial(initial)

# `client.monitor` is a context manager — the subscription is torn
# down on scope exit, even on exception.
with client.monitor(scope="hw:/usb/**") as events:
    for event in events:
        match event.kind:
            case "added":
                print(f"  + {event.urn}  {event.resource.vid:04x}:{event.resource.pid:04x}")
            case "removed":
                print(f"  - {event.urn}")
            case "state_changed":
                print(f"  Δ {event.urn}  {event.old_state} → {event.new_state}")
```

The iterator blocks until the next event; the thread is parked on the
futex behind the ring, not spinning. A wake costs one syscall.

### §3.1 Tuning the synthesizer

The broker synthesizes `state_changed` from `removed`+`added` pairs
within a debounce window when the identity comparator says they're the
same physical device (Part 5.3.4). Two knobs:

```python
# Future shape — Tier-1 stub today.
client.monitor(
    scope="hw:/usb/**",
    synthesize_changes=True,     # default; turn off for raw add/remove pairs
    debounce_ms=1000,            # the synthesizer's correlation window
)
```

Set `synthesize_changes=False` if you're writing a forensic logger
that *wants* to see the underlying flicker (debugging hub-power
issues, hunting transient unplugs).

---

## §4. Asynchronous monitor (asyncio service)

```python
# Future shape — Tier-1 stub today.
import asyncio
from mgf.hrb import Client

async def watch_usb():
    client = Client.connect("/tmp/mgf-hrb.sock", cap_id=my_observe_cap_id)
    async with client.amonitor(scope="hw:/usb/**") as events:
        async for event in events:
            await handle(event)

asyncio.run(watch_usb())
```

The `async for` cooperates with asyncio's loop. The SDK uses
`loop.add_reader(fd, ...)` on the underlying event-source fd, so the
service can interleave the monitor with any other asyncio work.

---

## §5. Qt monitor (PySide6 main thread)

```python
# Future shape — Tier-1 stub today.
from PySide6.QtCore import QSocketNotifier, QObject
from mgf.hrb import Client


class UsbMonitor(QObject):
    def __init__(self, parent=None):
        super().__init__(parent)
        self._client = Client.connect("/tmp/mgf-hrb.sock", cap_id=my_observe_cap_id)
        self._fd = self._client.monitor_fd(scope="hw:/usb/**")
        self._notifier = QSocketNotifier(self._fd, QSocketNotifier.Read, self)
        self._notifier.activated.connect(self._on_event)

    def _on_event(self, _fd: int):
        # drain_events is non-blocking; it returns every event ready now.
        for event in self._client.drain_events(self._fd):
            self._dispatch(event)

    def stop(self):
        self._notifier.setEnabled(False)
        self._client.monitor_close(self._fd)
```

The Qt main loop owns the fd; the notifier fires `_on_event` on the
main thread (where the model/view layer lives). No worker thread, no
cross-thread signal hops, no `qasync`-style shim. **The fd is the
contract** — the same primitive that drives `select(2)` and `epoll(7)`,
exposed to Qt's loop directly.

---

## §6. Identity in events — `previous` on `state_changed`

When the synthesizer collapses a `removed`+`added` pair into one
`state_changed`, the event carries both states:

```python
@dataclass(frozen=True, slots=True)
class Event:
    kind: Literal["added", "removed", "state_changed"]
    urn:  str
    timestamp: datetime
    # On added: full resource. On removed: the URN is enough. On
    # state_changed: both `resource` (the new state) and `previous`
    # (the pre-change snapshot).
    resource:  Resource | None       # current state, None on `removed`
    previous:  Resource | None       # only on synthesized state_changed
```

A typical use: render "Resource changed: X (was 0x12ab → now
0x1281)" without correlating two events client-side. The broker did
the correlation.

---

## §7. Threading & lifecycle

Per the SDK threading contract (Part 8.3.1):

- **Each monitor handle is single-thread-consume.** Iterating one
  `monitor()` from two threads raises `OperationUnsupportedError`. Two
  *different* `monitor()` calls from two threads is fine — they get
  independent streams.
- **The `Client` itself is thread-safe** for read-side calls (`list` /
  `inspect`). You can hold one client and call from several threads
  for those.
- **Closing the monitor releases the subscription.** Always use the
  context-manager form (`with` / `async with`) so a crash inside the
  block doesn't leak a server-side subscription.

---

## §8. Combining monitor with `claim`

A common shape: watch for a specific device to appear (in DFU, after a
mode change, after passthrough release), then claim it.

```python
# Future shape — Tier-1 stub today; see banner at top.
from mgf.hrb import ClaimMode

# Wait for the iFig to land in DFU mode.
with client.monitor(scope="hw:/usb/**") as events:
    for event in events:
        if event.kind != "added":
            continue
        d = event.resource
        if d.vid == 0x05ac and d.pid == 0x1227:  # iFig DFU PID
            ifig_dfu_urn = event.urn
            break

# Now claim it.
with client.claim(ifig_dfu_urn, ClaimMode.EXCLUSIVE, deadline_ms=5_000) as lease:
    raw = lease.raw(deadline_ms=1_000)
    flash_firmware(raw)
```

For the iFig-DFU shape specifically — where the device's PID and
serial change across the transition — see
[`track-a-device-across-a-mode-change.md`](track-a-device-across-a-mode-change.md),
which uses the identity comparator to know it's the *same* physical
iFig before and after.

---

## §9. Testing this pattern  *(future — pseudocode against not-yet-shipped fakes)*

> The `mgf.hrb.testing` module (`FakeResource`, `ScriptedBus`,
> `install_fake`) is designed in Part 8.6.1 but not yet published.
> The shape below is documented so consumers can plan their test
> infrastructure; runnable versions land with the broker-side
> subscribe protocol and the public test fakes (after the Tier-0
> survivability pass). The comparator-only half of this story is
> testable today — see
> [`track-a-device-across-a-mode-change.md`](track-a-device-across-a-mode-change.md)
> §6.

```python
# Future shape — Tier-1 stub today.
from mgf.hrb.testing import FakeResource, ScriptedBus, install_fake

def test_added_event_fires_for_new_device():
    bus = ScriptedBus()
    with install_fake([]) as fake_client:
        with fake_client.monitor(scope="hw:/usb/**") as events:
            bus.at(0.1).add(FakeResource.usb_interface(
                urn="hw:/usb/bus1/port-2/dev/if0",
                vid=0x05ac, pid=0x12ab,
            ))
            bus.advance(0.5)

            event = next(events)
            assert event.kind == "added"
            assert event.urn == "hw:/usb/bus1/port-2/dev/if0"


def test_synthesizer_collapses_mode_change():
    bus = ScriptedBus(FakeResource.usb_interface(
        urn="hw:/usb/bus1/port-2/dev/if0",
        vid=0x05ac, pid=0x12ab, serial="F4GS9PWWJCM7",
    ))

    with install_fake(bus) as fake_client:
        with fake_client.monitor(scope="hw:/usb/**") as events:
            # Remove the device, then re-add it with a different PID
            # within the debounce window. The synthesizer must merge.
            bus.at(0.0).remove("hw:/usb/bus1/port-2/dev/if0")
            bus.at(0.2).add(FakeResource.usb_interface(
                urn="hw:/usb/bus1/port-2/dev/if0",
                vid=0x05ac, pid=0x1281,  # mode change
                serial="F4GS9PWWJCM7",
            ))
            bus.advance(2.0)

            event = next(events)
            assert event.kind == "state_changed"
            assert event.previous.pid == 0x12ab
            assert event.resource.pid == 0x1281
```

`ScriptedBus`'s virtual clock makes multi-step transitions
deterministic — no `sleep`, no flakes. See Part 8.6.1 for the public
test surface.

---

## §10. Related

- `mgf-hrb-complete-design.md` Part 5.3 (the shm ring), Part 5.3.4
  (the synthesizer), Part 8.2 (role-interfaces), Part 8.3 (the
  three-layer event API).
- `mgf-hrb-usb-worker.md` §5.3 (netlink → typed events; netlink
  overrun handling — the gap-honesty story).
- [`track-a-device-across-a-mode-change.md`](track-a-device-across-a-mode-change.md)
  — the identity comparator on top of this pattern.
