|
| 1 | +# Discovery |
| 2 | + |
| 3 | +Device Connect uses one selector grammar to address devices, functions, and |
| 4 | +events. The same selector string drives discovery: it tells the system |
| 5 | +**which** entities you mean. Labels attached to devices, functions, and |
| 6 | +events provide the dimensions to filter on. |
| 7 | + |
| 8 | +This guide covers the labels schema, the selector grammar, and the two |
| 9 | +tools that resolve selectors. |
| 10 | + |
| 11 | +## Labels |
| 12 | + |
| 13 | +Labels are key/value metadata. Values are strings or lists of strings. |
| 14 | +Lists express composite identity (a smart camera that is both `camera` and |
| 15 | +`inference`). |
| 16 | + |
| 17 | +Drivers declare labels in two places: |
| 18 | + |
| 19 | +```python |
| 20 | +class SmartCamera(DeviceDriver): |
| 21 | + labels = { |
| 22 | + "category": ["camera", "inference"], |
| 23 | + "location": "lab-A/optics-bench", |
| 24 | + } |
| 25 | + |
| 26 | + @rpc(labels={"direction": "write", "modality": ["rgb", "4k"]}) |
| 27 | + async def capture_image(self, resolution: str = "1080p") -> dict: |
| 28 | + ... |
| 29 | + |
| 30 | + @emit(labels={"modality": "motion"}) |
| 31 | + async def state_change_detected(self, zone_id: str, state_class: str): |
| 32 | + ... |
| 33 | +``` |
| 34 | + |
| 35 | +### Well-known keys |
| 36 | + |
| 37 | +These keys carry conventional meaning. Custom keys are always allowed |
| 38 | +alongside them. |
| 39 | + |
| 40 | +| Question | Key | Applies to | Example values | |
| 41 | +| --- | --- | --- | --- | |
| 42 | +| What is it? | `category` | device | `camera`, `robot`, `hub`, `sensor`, `actuator`, `inference` | |
| 43 | +| Where is it? | `location` | device | `lab-A`, `zone-A/dock` (`/`-hierarchical, glob-able) | |
| 44 | +| Read or write? | `direction` | function (RPC) | `read`, `write` | |
| 45 | +| Is it dangerous? | `safety` | function + event | `critical`, `informational` | |
| 46 | +| What kind of signal? | `modality` | function + event | `rgb`, `thermal`, `infrared`, `motion`, `4k`, ... | |
| 47 | + |
| 48 | +The RPC-vs-event distinction is structural (FunctionDef vs EventDef) and is |
| 49 | +expressed by the selector scope, not by a label. |
| 50 | + |
| 51 | +### Drivers without label declarations |
| 52 | + |
| 53 | +Drivers that populate only the legacy `DeviceStatus.location` heartbeat |
| 54 | +field are still discoverable by location: the value is mirrored into |
| 55 | +`labels["location"]` at the discovery boundary so selector queries on |
| 56 | +location work without a driver change. |
| 57 | + |
| 58 | +## Selector grammar |
| 59 | + |
| 60 | +``` |
| 61 | +device(<filters>) device-only |
| 62 | +device(<filters>).function(<filters>) RPCs on a device subset |
| 63 | +device(<filters>).event(<filters>) events on a device subset |
| 64 | +function(<filters>) all RPCs across the fleet |
| 65 | +event(<filters>) all events across the fleet |
| 66 | +``` |
| 67 | + |
| 68 | +Inside `(...)`: |
| 69 | + |
| 70 | +- `key:value` - single-value match |
| 71 | +- `key:[v1,v2]` - OR within a key (matches if the label value contains any |
| 72 | + listed value; multi-valued labels match if any element is in the list) |
| 73 | +- `key:pattern*` - anchored glob (`*`, `?`); `set_*` matches `set_threshold` |
| 74 | + but not `unset_threshold`. Use `*set*` for substring. |
| 75 | +- `k1:v1,k2:v2` - AND across keys |
| 76 | +- bare string (no colon) - id/name match: `device(robot-001)`, |
| 77 | + `function(capture_image)`. Globs allowed: `device(cam-*)`. |
| 78 | +- `*` or empty - match all |
| 79 | + |
| 80 | +Keys inside `device(...)` resolve against device labels; keys inside |
| 81 | +`function(...)` resolve against function labels; keys inside `event(...)` |
| 82 | +resolve against event labels. The `.` chains: "narrow to these devices, |
| 83 | +then narrow to these functions or events on them." |
| 84 | + |
| 85 | +### Case sensitivity |
| 86 | + |
| 87 | +Selector matching is **case-sensitive** on both label keys and values, so |
| 88 | +`device(category:Camera)` and `device(category:camera)` are not |
| 89 | +equivalent. Kubernetes label selectors and AWS resource tag matching are |
| 90 | +case-sensitive; we follow that convention. Use lowercase for label keys |
| 91 | +and values as the convention in this repo; drivers in `tests/drivers/` |
| 92 | +follow that. |
| 93 | + |
| 94 | +### Selector examples |
| 95 | + |
| 96 | +``` |
| 97 | +device(category:camera) all cameras |
| 98 | +device(category:[camera,robot], location:lab-A/*) cameras or robots in lab-A |
| 99 | +device(location:lab-A*) lab-A and any descendant |
| 100 | +device(*).function(direction:write, modality:rgb) rgb-producing writes fleet-wide |
| 101 | +device(*).event(modality:motion) all motion events |
| 102 | +function(safety:critical) critical RPCs fleet-wide |
| 103 | +function(estop) fleet emergency-stop targets |
| 104 | +``` |
| 105 | + |
| 106 | +## Tools |
| 107 | + |
| 108 | +### `discover(selector, offset=0, limit=200)` |
| 109 | + |
| 110 | +Resolves a selector to matched entities. Returns devices, function tuples, |
| 111 | +or event tuples depending on the selector scope. The response includes a |
| 112 | +`label_histogram` so you can see which dimensions to narrow on next without |
| 113 | +a separate call. |
| 114 | + |
| 115 | +`discover()` includes full schemas inline when the matched set is small, |
| 116 | +and switches to a name-and-labels summary above |
| 117 | +`DEVICE_CONNECT_FUNCTION_THRESHOLD` (default 20). The threshold is |
| 118 | +configurable via environment variable. |
| 119 | + |
| 120 | +### `discover_labels(key=None, offset=0, limit=50)` |
| 121 | + |
| 122 | +Returns the fleet label vocabulary. Use this first when you do not know |
| 123 | +which dimensions are available. |
| 124 | + |
| 125 | +- With no `key`: returns top values per key across each axis (`device_keys`, |
| 126 | + `function_keys`, `event_keys`). |
| 127 | +- With a `key` like `"device.location"` or `"function.direction"`: |
| 128 | + paginates the full value list for that one key. |
| 129 | + |
| 130 | +## Response envelopes |
| 131 | + |
| 132 | +The three response shapes below — one for `discover`, two for |
| 133 | +`discover_labels` — are the source of truth for callers. Fields not |
| 134 | +listed are reserved for forward-compatible extensions; do not rely on |
| 135 | +field order. |
| 136 | + |
| 137 | +### `discover` |
| 138 | + |
| 139 | +```json |
| 140 | +{ |
| 141 | + "scope": "device_only", |
| 142 | + "matched": 47, |
| 143 | + "returned": 20, |
| 144 | + "offset": 0, |
| 145 | + "next_offset": 20, |
| 146 | + "results": [...], |
| 147 | + "label_histogram": { |
| 148 | + "category": { |
| 149 | + "values": {"camera": 312, "robot": 89, "sensor": 601}, |
| 150 | + "multivalued": true, |
| 151 | + "unique_devices": 1002 |
| 152 | + } |
| 153 | + } |
| 154 | +} |
| 155 | +``` |
| 156 | + |
| 157 | +Fields: |
| 158 | + |
| 159 | +- `scope` - one of `device_only`, `device_function`, `device_event`, |
| 160 | + `function_only`, `event_only`. |
| 161 | +- `matched` - total matched entities (across all pages). |
| 162 | +- `returned` - rows in this page. |
| 163 | +- `offset` / `next_offset` - pagination cursor; `next_offset` is `null` when |
| 164 | + no more pages. |
| 165 | +- `results` - per-page rows. Shape depends on scope (devices, function |
| 166 | + tuples, or event tuples). |
| 167 | +- `label_histogram` - per-key vocabulary across the matched set |
| 168 | + (pre-pagination), so you can choose how to narrow next. On the device |
| 169 | + axis, multi-valued keys also carry `unique_devices`. |
| 170 | + |
| 171 | +The hard ceiling on `limit` is 1000 to prevent runaway responses; ask for |
| 172 | +more pages instead. |
| 173 | + |
| 174 | +### `discover_labels` — multi-axis form (no `key`) |
| 175 | + |
| 176 | +```json |
| 177 | +{ |
| 178 | + "total_devices": 1247, |
| 179 | + "total_functions": 7100, |
| 180 | + "total_events": 1292, |
| 181 | + "device_keys": { |
| 182 | + "category": { |
| 183 | + "values": {"camera": 312, "robot": 89, "sensor": 601}, |
| 184 | + "multivalued": true, |
| 185 | + "unique_devices": 1002 |
| 186 | + }, |
| 187 | + "location": { |
| 188 | + "values": {"warehouse1/loading-dock": 120, "warehouse1/yard": 80, "lab-A/optics-bench": 45}, |
| 189 | + "more": 1227 |
| 190 | + } |
| 191 | + }, |
| 192 | + "function_keys": { |
| 193 | + "direction": {"values": {"read": 4200, "write": 2900}} |
| 194 | + }, |
| 195 | + "event_keys": { |
| 196 | + "modality": {"values": {"motion": 812, "thermal": 480}} |
| 197 | + } |
| 198 | +} |
| 199 | +``` |
| 200 | + |
| 201 | +Fields: |
| 202 | + |
| 203 | +- `total_devices` / `total_functions` / `total_events` - fleet-wide entity |
| 204 | + counts on each axis. |
| 205 | +- `device_keys` / `function_keys` / `event_keys` - per-axis vocabulary. |
| 206 | + Each value is a map of label key → entry, where each entry contains: |
| 207 | + - `values` - `{value: count}` map sorted by descending count, capped |
| 208 | + at the top-N most-frequent values per key (default `20`, |
| 209 | + configurable via `DEVICE_CONNECT_LABEL_VALUES_TOP_N`). |
| 210 | + - `more` - present and `> 0` iff the value list was cropped; the |
| 211 | + integer count of values omitted from this page. Omitted when no |
| 212 | + truncation occurred. To enumerate the full list, switch to the |
| 213 | + per-key form (`discover_labels(key="device.location")`). |
| 214 | + - `multivalued` - present and `true` iff at least one entity carries a |
| 215 | + list value for this key. Omitted when the key is single-valued |
| 216 | + everywhere on this axis. |
| 217 | + - `unique_devices` - device-axis only; the number of devices that |
| 218 | + carry this key at least once (deduplicates list values). Omitted on |
| 219 | + the function and event axes. |
| 220 | + |
| 221 | +The same per-key entry shape is used inside `discover()`'s |
| 222 | +`label_histogram`, including `more` truncation. Per-key |
| 223 | +`discover_labels(key=...)` enumerates fully via its own pagination |
| 224 | +cursor and is not truncated. |
| 225 | + |
| 226 | +### `discover_labels` — per-key form (`key="device.location"`, etc.) |
| 227 | + |
| 228 | +This form is paginated, not truncated: every distinct value is reachable |
| 229 | +across pages via the `offset` / `next_offset` cursor. There is no |
| 230 | +`more` field here; that field is specific to the multi-axis form above. |
| 231 | + |
| 232 | +```json |
| 233 | +{ |
| 234 | + "axis": "device", |
| 235 | + "key": "location", |
| 236 | + "matched": 247, |
| 237 | + "returned": 50, |
| 238 | + "offset": 0, |
| 239 | + "next_offset": 50, |
| 240 | + "values": {"lab-A/optics-bench": 12, "lab-A/dock": 9, "warehouse1/yard": 8}, |
| 241 | + "axis_total": 1247, |
| 242 | + "multivalued": true |
| 243 | +} |
| 244 | +``` |
| 245 | + |
| 246 | +Fields: |
| 247 | + |
| 248 | +- `axis` - one of `"device"`, `"function"`, `"event"` (parsed from the |
| 249 | + dotted `key` argument). |
| 250 | +- `key` - the label key without the axis prefix. |
| 251 | +- `matched` - total distinct values for this key on this axis (across |
| 252 | + all pages). |
| 253 | +- `returned` - values on this page. |
| 254 | +- `offset` / `next_offset` - pagination cursor; `next_offset` is `null` |
| 255 | + when no more pages. |
| 256 | +- `values` - `{value: count}` map for this page, sorted by descending |
| 257 | + count. |
| 258 | +- `axis_total` - total entities on this axis (e.g., devices when |
| 259 | + `axis == "device"`); use as the denominator if you want coverage |
| 260 | + percentages. |
| 261 | +- `multivalued` - present and `true` iff this key is multivalued on this |
| 262 | + axis. Omitted otherwise. |
| 263 | + |
| 264 | +## Error responses |
| 265 | + |
| 266 | +`discover` and `discover_labels` return errors as data inside the response |
| 267 | +envelope rather than raising. The shape is stable so callers can branch on |
| 268 | +the `code` programmatically and surface `message` to logs or users: |
| 269 | + |
| 270 | +```json |
| 271 | +{ "matched": 0, "returned": 0, "offset": 0, "next_offset": null, |
| 272 | + "results": [], |
| 273 | + "error": { |
| 274 | + "code": "selector_parse_error", |
| 275 | + "message": "Unknown scope 'widgets' at position 0\n widgets(*)\n ^" |
| 276 | + } |
| 277 | +} |
| 278 | +``` |
| 279 | + |
| 280 | +| Code | Cause | |
| 281 | +| --- | --- | |
| 282 | +| `invalid_selector` | Selector is not a string (or otherwise unusable as input) | |
| 283 | +| `selector_parse_error` | Selector is a string but malformed | |
| 284 | +| `connection_error` | Registry or messaging backend unavailable | |
| 285 | +| `key_not_axis_qualified` | `discover_labels(key=...)` missing the `device.` / `function.` / `event.` prefix | |
| 286 | +| `unknown_axis` | `discover_labels(key=...)` axis prefix not in `{device, function, event}` | |
| 287 | + |
| 288 | +## Worked examples |
| 289 | + |
| 290 | +### Browse the fleet vocabulary |
| 291 | + |
| 292 | +```python |
| 293 | +from device_connect_agent_tools import connect, discover_labels |
| 294 | + |
| 295 | +connect() |
| 296 | +vocab = discover_labels() |
| 297 | +# {"total_devices": 1247, "total_functions": 7100, "total_events": 1292, |
| 298 | +# "device_keys": {"category": {...}, "location": {...}}, |
| 299 | +# "function_keys": {"direction": {...}, "modality": {...}, "safety": {...}}, |
| 300 | +# "event_keys": {"modality": {...}}} |
| 301 | + |
| 302 | +# Drill into one dimension: |
| 303 | +locations = discover_labels(key="device.location", limit=50) |
| 304 | +``` |
| 305 | + |
| 306 | +### Find every camera in lab-A |
| 307 | + |
| 308 | +```python |
| 309 | +from device_connect_agent_tools import discover |
| 310 | + |
| 311 | +result = discover("device(category:camera, location:lab-A/*)") |
| 312 | +for d in result["results"]: |
| 313 | + print(d["device_id"], d["labels"]) |
| 314 | +``` |
| 315 | + |
| 316 | +### Find every write RPC on cameras, fleet-wide |
| 317 | + |
| 318 | +```python |
| 319 | +result = discover("device(category:camera).function(direction:write)") |
| 320 | +for row in result["results"]: |
| 321 | + print(row["device_id"], row["name"]) |
| 322 | +``` |
| 323 | + |
| 324 | +### Paginate a large result set |
| 325 | + |
| 326 | +```python |
| 327 | +offset = 0 |
| 328 | +while True: |
| 329 | + page = discover("device(*)", offset=offset, limit=200) |
| 330 | + for d in page["results"]: |
| 331 | + process(d) |
| 332 | + if page["next_offset"] is None: |
| 333 | + break |
| 334 | + offset = page["next_offset"] |
| 335 | +``` |
| 336 | + |
| 337 | +## Known limits |
| 338 | + |
| 339 | +### Client-side filtering (v1) |
| 340 | + |
| 341 | +`discover()` and `discover_labels()` currently load the full fleet via |
| 342 | +`Connection.list_devices()` and apply the selector in-process. This is |
| 343 | +fine at today's fleet sizes (low hundreds of devices) but does not scale |
| 344 | +to the 10K-device worked example: the entire device list crosses the |
| 345 | +wire on every call, regardless of how selective the selector is. |
| 346 | + |
| 347 | +Push-down to the registry is intentionally deferred for v1 — the |
| 348 | +selector grammar and response envelopes are designed so that swapping |
| 349 | +the in-process filter for a registry-side query is a transparent |
| 350 | +optimization, not a breaking change. Until then, callers running |
| 351 | +against large fleets should: |
| 352 | + |
| 353 | +- prefer `discover_labels(key=…)` over `discover()` when they only need |
| 354 | + vocabulary, and |
| 355 | +- treat `discover("device(*)")` as an O(fleet) operation, not O(matched). |
| 356 | + |
| 357 | +The operations layer should plan for push-down ahead of fleet growth |
| 358 | +past ~1K devices. |
0 commit comments