sofapython 0.0.1rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,713 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from collections import defaultdict, deque
5
+ from typing import Any, Callable, Deque, Dict, Literal, Mapping, Optional
6
+
7
+ from .hub_versions import HUB_VERSION_X1, HUB_VERSION_X1S, HUB_VERSION_X2
8
+ from .commands import (
9
+ COMMAND_RECORD_STRIDE_X1,
10
+ COMMAND_RECORD_STRIDE_X1S_X2,
11
+ KEYMAP_RECORD_SIZE,
12
+ iter_command_records_from_assembled,
13
+ iter_keymap_records,
14
+ )
15
+ from .protocol_const import (
16
+ BUTTONNAME_BY_CODE,
17
+ DEVICE_CLASS_WIFI_IP,
18
+ classify_device_class_code,
19
+ normalize_device_class,
20
+ )
21
+
22
+
23
+ def normalize_device_entry(
24
+ device: dict[str, Any] | None,
25
+ *,
26
+ default_class: str | None = None,
27
+ default_class_code: int | None = None,
28
+ ) -> dict[str, Any]:
29
+ """Return a cache-safe device row with normalized type metadata."""
30
+
31
+ source = dict(device) if isinstance(device, dict) else {}
32
+
33
+ brand = str(source.get("brand") or source.get("brand_name") or "").strip()
34
+ name = str(source.get("name") or source.get("device_name") or source.get("label") or "").strip()
35
+ device_class = normalize_device_class(
36
+ source.get("device_class", source.get("device_type"))
37
+ )
38
+
39
+ raw_class_code = source.get("device_class_code", source.get("device_type_code"))
40
+ try:
41
+ device_class_code = int(raw_class_code) & 0xFF
42
+ except (TypeError, ValueError):
43
+ device_class_code = None
44
+
45
+ if device_class_code is None and default_class_code is not None:
46
+ device_class_code = int(default_class_code) & 0xFF
47
+
48
+ if device_class is None and default_class is not None:
49
+ device_class = normalize_device_class(default_class)
50
+
51
+ if device_class is None and device_class_code is not None:
52
+ device_class = classify_device_class_code(device_class_code)
53
+
54
+ if brand:
55
+ source["brand"] = brand
56
+ else:
57
+ source.pop("brand", None)
58
+
59
+ if name:
60
+ source["name"] = name
61
+ else:
62
+ source.pop("name", None)
63
+
64
+ if device_class is not None:
65
+ source["device_class"] = device_class
66
+ else:
67
+ source.pop("device_class", None)
68
+
69
+ if device_class_code is not None:
70
+ source["device_class_code"] = device_class_code
71
+ else:
72
+ source.pop("device_class_code", None)
73
+
74
+ # Strip the temporary field names so exported cache only emits the new schema.
75
+ source.pop("device_type", None)
76
+ source.pop("device_type_code", None)
77
+
78
+ return source
79
+
80
+ class ActivityCache:
81
+ def __init__(self) -> None:
82
+ self.current_activity: Optional[int] = None
83
+ self.current_activity_hint: Optional[int] = None
84
+ self.activities: Dict[int, Dict[str, Any]] = {}
85
+ self.devices: Dict[int, Dict[str, Any]] = {}
86
+ self.buttons: Dict[int, set[int]] = {}
87
+ # Per-button mapping details: act_lo → {button_id → {device_id, command_id, long_press_device_id?, long_press_command_id?}}
88
+ self.button_details: Dict[int, Dict[int, Dict[str, int]]] = defaultdict(dict)
89
+ self.commands: dict[int, dict[int, str]] = defaultdict(dict)
90
+ # Per-command record metadata captured at REQ_COMMANDS parse
91
+ # time. Keyed dev_id -> command_id -> {"library_type": int,
92
+ # "button_code": int}. ``library_type`` is the codec selector
93
+ # the hub stored alongside the command (0x0D for IR-DB, others
94
+ # for BT/RF/learned). ``button_code`` is the 48-bit canonical
95
+ # command identifier the hub uses when keymap entries or macro
96
+ # steps reference this command. Both are needed for a faithful
97
+ # restore. Backed by the bytes ``CommandRecord.control[0]`` and
98
+ # ``CommandRecord.control[1:7]`` respectively.
99
+ self.command_metadata: dict[int, dict[int, dict[str, int]]] = defaultdict(dict)
100
+ self.ip_devices: Dict[int, Dict[str, Any]] = {}
101
+ self.ip_buttons: Dict[int, Dict[int, Dict[str, Any]]] = defaultdict(dict)
102
+ self.activity_command_refs: dict[int, set[tuple[int, int]]] = defaultdict(set)
103
+ self.activity_favorite_slots: dict[int, list[dict[str, int]]] = defaultdict(list)
104
+ self.activity_keybinding_slots: dict[int, list[dict[str, int]]] = defaultdict(list)
105
+ self.activity_members: dict[int, set[int]] = defaultdict(set)
106
+ # Favorites ordering: maps act_lo → list of (fav_id, slot) pairs in hub order
107
+ # Populated by OP_FAV_ORDER_RESP (family 0x63) response to OP_FAV_ORDER_REQ (0x0162)
108
+ self.activity_favorites_order: dict[int, list[tuple[int, int]]] = {}
109
+ self.device_key_sorts: dict[int, dict[str, Any]] = {}
110
+ self.activity_favorite_labels: dict[int, dict[tuple[int, int], str]] = defaultdict(dict)
111
+ self.activity_keybinding_labels: dict[int, dict[tuple[int, int], str]] = defaultdict(dict)
112
+ self.activity_macros: dict[int, list[dict[str, int | str]]] = defaultdict(list)
113
+ # Only track the most recent activation to avoid unbounded growth
114
+ self.app_activations: Deque[dict[str, Any]] = deque(maxlen=1)
115
+
116
+ def entities(
117
+ self, kind: Literal["device", "activity"]
118
+ ) -> Mapping[int, Dict[str, Any]]:
119
+ """Return the live id-keyed map for ``kind``.
120
+
121
+ Read-side accessor that lets call sites name the entity kind
122
+ instead of hard-coding the attribute. The returned mapping is
123
+ the same dict the cache uses internally, so callers should
124
+ treat it as read-only: mutations should still go through the
125
+ dedicated mutator methods on the cache (or, where one does
126
+ not yet exist, the direct attribute) so a future migration to
127
+ a typed container has one place to land.
128
+
129
+ ``ip_devices`` remains a separate namespace and is not
130
+ unified by this accessor; callers that want the union still
131
+ reach into both maps explicitly.
132
+ """
133
+
134
+ if kind == "device":
135
+ return self.devices
136
+ if kind == "activity":
137
+ return self.activities
138
+ raise ValueError(
139
+ f"ActivityCache.entities: unknown kind={kind!r}; "
140
+ "expected 'device' or 'activity'"
141
+ )
142
+
143
+ def set_hint(self, activity_id: Optional[int]) -> None:
144
+ self.current_activity_hint = activity_id
145
+
146
+ def update_activity_state(self) -> tuple[Optional[int], Optional[int]]:
147
+ if self.current_activity != self.current_activity_hint:
148
+ old = self.current_activity
149
+ self.current_activity = self.current_activity_hint
150
+ return self.current_activity, old
151
+ return self.current_activity, self.current_activity
152
+
153
+ def get_activity_name(self, act_id: Optional[int]) -> Optional[str]:
154
+ if act_id is None:
155
+ return None
156
+ return self.activities.get(act_id & 0xFF, {}).get("name")
157
+
158
+ def replace_keymap_rows(self, act_lo: int, row_stream: bytes) -> None:
159
+ """Replace the physical-button view for ``act_lo`` from an assembled row stream.
160
+
161
+ Record-walking uses :func:`commands.iter_keymap_records`, which
162
+ encodes the documented 18-byte fixed-stride layout. The activity-id
163
+ filter inside the iterator subsumes the previous explicit
164
+ ``act != act_lo`` early-return in ``_parse_keymap_record``.
165
+
166
+ A short trailing fragment shorter than 18 bytes that looks like a
167
+ valid record start is right-padded with zeros and processed via the
168
+ usual record classifier. That compatibility fallback is preserved
169
+ because some hub firmwares have been observed to truncate the final
170
+ record.
171
+ """
172
+
173
+ self.buttons[act_lo] = set()
174
+ self.button_details.pop(act_lo, None)
175
+
176
+ favorites_allowed = True
177
+
178
+ for record in iter_keymap_records(row_stream, expected_activity_id=act_lo):
179
+ favorites_allowed, _ = self._parse_keymap_record(
180
+ act_lo,
181
+ record.raw,
182
+ favorites_allowed=favorites_allowed,
183
+ )
184
+
185
+ usable = len(row_stream) - (len(row_stream) % KEYMAP_RECORD_SIZE)
186
+ remainder = row_stream[usable:]
187
+ if (
188
+ len(remainder) >= 2
189
+ and remainder[0] == act_lo
190
+ and remainder[1] in BUTTONNAME_BY_CODE
191
+ ):
192
+ padded = remainder + b"\x00" * (KEYMAP_RECORD_SIZE - len(remainder))
193
+ self._parse_keymap_record(
194
+ act_lo,
195
+ padded,
196
+ favorites_allowed=favorites_allowed,
197
+ )
198
+
199
+ def _parse_keymap_record(
200
+ self, act_lo: int, record: bytes, *, favorites_allowed: bool
201
+ ) -> tuple[bool, bool]:
202
+ act = record[0] if record else None
203
+ if act != act_lo:
204
+ return favorites_allowed, False
205
+
206
+ button_id = record[1]
207
+ device_id = record[2]
208
+ command_id = record[9] if len(record) > 9 else button_id
209
+
210
+ if button_id in BUTTONNAME_BY_CODE:
211
+ self.buttons[act_lo].add(button_id)
212
+ details: Dict[str, int] = {"device_id": device_id, "command_id": command_id}
213
+ # Per the official KeyToKeyGets parser, each 18-byte keymap
214
+ # record's long-press triple lives at:
215
+ # [10] long_press_device_id
216
+ # [11..16] long_press_button_code (6B BE)
217
+ # [17] long_press_button_id (== long_press_command_id)
218
+ # A row with no long press is simply ``long_press_device_id == 0``.
219
+ # Earlier code additionally required ``record[11:15] == 0`` and
220
+ # ``record[15] == 0x4E`` -- a signature that only matches the
221
+ # *synthetic* button codes our own writer produces, so genuine
222
+ # captured long-press codes (real IR, BT, etc.) were silently
223
+ # dropped on backup.
224
+ if len(record) >= 18 and record[10] != 0:
225
+ details["long_press_device_id"] = record[10]
226
+ details["long_press_command_id"] = record[17]
227
+ self.button_details[act_lo][button_id] = details
228
+ return False, True
229
+
230
+ if favorites_allowed:
231
+ self._upsert_activity_favorite_slot(
232
+ act_lo,
233
+ button_id=button_id,
234
+ device_id=device_id,
235
+ command_id=command_id,
236
+ source="keymap",
237
+ )
238
+ return True, True
239
+ return favorites_allowed, False
240
+
241
+ def _upsert_activity_keybinding_slot(
242
+ self,
243
+ act_lo: int,
244
+ *,
245
+ button_id: int,
246
+ device_id: int,
247
+ command_id: int,
248
+ source: str,
249
+ ) -> None:
250
+ pair = (device_id & 0xFF, command_id & 0xFF)
251
+ if pair[0] == 0 or pair[1] in (0x00, 0xFC):
252
+ return
253
+
254
+ self.activity_command_refs[act_lo].add(pair)
255
+ slots = self.activity_keybinding_slots[act_lo]
256
+
257
+ for idx, slot in enumerate(slots):
258
+ if slot["button_id"] != (button_id & 0xFF):
259
+ continue
260
+ existing_source = slot.get("source", "keymap")
261
+ # Preserve legacy activity-map slots for compatibility, but treat
262
+ # keymap-derived data as the authoritative source when both exist.
263
+ if existing_source == "activity_map" and source != "activity_map":
264
+ slots[idx] = {
265
+ "button_id": button_id & 0xFF,
266
+ "device_id": pair[0],
267
+ "command_id": pair[1],
268
+ "source": source,
269
+ }
270
+ else:
271
+ slots[idx].update({
272
+ "device_id": pair[0],
273
+ "command_id": pair[1],
274
+ "source": source,
275
+ })
276
+ return
277
+
278
+ slots.append(
279
+ {
280
+ "button_id": button_id & 0xFF,
281
+ "device_id": pair[0],
282
+ "command_id": pair[1],
283
+ "source": source,
284
+ }
285
+ )
286
+
287
+ def _upsert_activity_favorite_slot(
288
+ self,
289
+ act_lo: int,
290
+ *,
291
+ button_id: int,
292
+ device_id: int,
293
+ command_id: int,
294
+ source: str,
295
+ ) -> None:
296
+ pair = (device_id & 0xFF, command_id & 0xFF)
297
+ if pair[0] == 0 or pair[1] in (0x00, 0xFC):
298
+ return
299
+
300
+ self.activity_command_refs[act_lo].add(pair)
301
+ slots = self.activity_favorite_slots[act_lo]
302
+
303
+ for idx, slot in enumerate(slots):
304
+ if (slot["device_id"], slot["command_id"]) != pair:
305
+ continue
306
+ existing_source = slot.get("source", "keymap")
307
+ # Preserve legacy activity-map slots for compatibility, but prefer
308
+ # keymap-derived favorite rows when both describe the same pair.
309
+ if existing_source == "activity_map" and source != "activity_map":
310
+ slots[idx] = {
311
+ "button_id": button_id,
312
+ "device_id": pair[0],
313
+ "command_id": pair[1],
314
+ "source": source,
315
+ }
316
+ return
317
+
318
+ slots.append(
319
+ {
320
+ "button_id": button_id,
321
+ "device_id": pair[0],
322
+ "command_id": pair[1],
323
+ "source": source,
324
+ }
325
+ )
326
+
327
+ def get_activity_command_refs(self, act_lo: int) -> set[tuple[int, int]]:
328
+ """Return the set of (device_id, command_id) pairs for the activity."""
329
+
330
+ return set(self.activity_command_refs.get(act_lo, set()))
331
+
332
+ def get_activity_favorite_slots(self, act_lo: int) -> list[dict[str, int]]:
333
+ """Return metadata for favorite slots in this activity."""
334
+
335
+ return list(self.activity_favorite_slots.get(act_lo, []))
336
+
337
+ def get_activity_keybinding_slots(self, act_lo: int) -> list[dict[str, int]]:
338
+ """Return metadata for keybinding slots in this activity."""
339
+
340
+ return list(self.activity_keybinding_slots.get(act_lo, []))
341
+
342
+ def record_activity_member(self, act_lo: int, device_id: int) -> None:
343
+ """Record a device as being linked to the activity."""
344
+
345
+ dev_lo = device_id & 0xFF
346
+ if dev_lo:
347
+ self.activity_members[act_lo & 0xFF].add(dev_lo)
348
+
349
+ def get_activity_members(self, act_lo: int) -> list[int]:
350
+ """Return linked device ids discovered for the activity."""
351
+
352
+ return sorted(self.activity_members.get(act_lo & 0xFF, set()))
353
+
354
+ def record_activity_mapping(
355
+ self,
356
+ act_lo: int,
357
+ device_id: int,
358
+ command_id: int,
359
+ *,
360
+ button_id: int | None = None,
361
+ ) -> None:
362
+ """Record a legacy activity-map favorite mapping entry.
363
+
364
+ Current protocol findings suggest activity favorites primarily come
365
+ from REQ_BUTTONS/keymap rows; REQ_ACTIVITY_MAP is now treated as a
366
+ membership roster. This helper remains for compatibility with restored
367
+ cache data and older tests.
368
+ """
369
+
370
+ dev_lo = device_id & 0xFF
371
+ self.record_activity_member(act_lo, dev_lo)
372
+
373
+ self._upsert_activity_favorite_slot(
374
+ act_lo,
375
+ button_id=button_id if button_id is not None else 0,
376
+ device_id=device_id & 0xFF,
377
+ command_id=command_id & 0xFF,
378
+ source="activity_map",
379
+ )
380
+
381
+ def record_favorite_label(
382
+ self, act_lo: int, device_id: int, command_id: int, label: str
383
+ ) -> None:
384
+ """Store the resolved label for a favorite command."""
385
+
386
+ self.activity_favorite_labels[act_lo][(device_id, command_id)] = label
387
+
388
+ def record_keybinding_label(
389
+ self, act_lo: int, device_id: int, command_id: int, label: str
390
+ ) -> None:
391
+ """Store the resolved label for an activity keybinding command."""
392
+
393
+ self.activity_keybinding_labels[act_lo][(device_id, command_id)] = label
394
+
395
+ def get_favorite_label(
396
+ self, act_lo: int, device_id: int, command_id: int
397
+ ) -> str | None:
398
+ """Return the known label for a favorite command, if any."""
399
+
400
+ return self.activity_favorite_labels.get(act_lo, {}).get((device_id, command_id))
401
+
402
+ def get_keybinding_label(
403
+ self, act_lo: int, device_id: int, command_id: int
404
+ ) -> str | None:
405
+ """Return the known label for an activity keybinding command, if any."""
406
+
407
+ return self.activity_keybinding_labels.get(act_lo, {}).get((device_id, command_id))
408
+
409
+ def get_activity_favorite_labels(self, act_lo: int) -> list[dict[str, int | str]]:
410
+ """Return favorite slots decorated with resolved labels."""
411
+
412
+ slots = self.activity_favorite_slots.get(act_lo, [])
413
+ labels = self.activity_favorite_labels.get(act_lo, {})
414
+
415
+ favorites: list[dict[str, int | str]] = []
416
+ seen: set[tuple[int, int]] = set()
417
+ for slot in slots:
418
+ pair = (slot["device_id"], slot["command_id"])
419
+ if pair in seen:
420
+ continue
421
+ label = labels.get(pair)
422
+ if not label:
423
+ continue
424
+ seen.add(pair)
425
+ favorites.append(
426
+ {
427
+ "name": label,
428
+ "device_id": slot["device_id"],
429
+ "command_id": slot["command_id"],
430
+ }
431
+ )
432
+
433
+ return favorites
434
+
435
+ def get_activity_keybinding_labels(self, act_lo: int) -> list[dict[str, int | str]]:
436
+ """Return keybinding slots decorated with resolved labels."""
437
+
438
+ slots = self.activity_keybinding_slots.get(act_lo, [])
439
+ labels = self.activity_keybinding_labels.get(act_lo, {})
440
+
441
+ keybindings: list[dict[str, int | str]] = []
442
+ seen: set[int] = set()
443
+ for slot in slots:
444
+ button_id = slot["button_id"]
445
+ if button_id in seen:
446
+ continue
447
+ pair = (slot["device_id"], slot["command_id"])
448
+ label = labels.get(pair)
449
+ if not label:
450
+ continue
451
+ seen.add(button_id)
452
+ keybindings.append(
453
+ {
454
+ "button_id": button_id,
455
+ "name": label,
456
+ "device_id": slot["device_id"],
457
+ "command_id": slot["command_id"],
458
+ }
459
+ )
460
+
461
+ return keybindings
462
+
463
+ def replace_activity_macros(
464
+ self, act_lo: int, macros: list[dict[str, int | str]]
465
+ ) -> None:
466
+ """Replace the cached macro list for ``act_lo``."""
467
+
468
+ self.activity_macros[act_lo & 0xFF] = list(macros)
469
+
470
+ def append_activity_macro(self, act_lo: int, command_id: int, label: str) -> None:
471
+ """Record a single macro entry for an activity."""
472
+
473
+ target = self.activity_macros.setdefault(act_lo & 0xFF, [])
474
+ for entry in target:
475
+ if entry.get("command_id") == command_id:
476
+ entry["label"] = label
477
+ return
478
+
479
+ target.append({"command_id": command_id, "label": label})
480
+
481
+ def get_activity_macros(self, act_lo: int) -> list[dict[str, int | str]]:
482
+ """Return the known macro definitions for ``act_lo``."""
483
+
484
+ return list(self.activity_macros.get(act_lo & 0xFF, []))
485
+
486
+ def parse_device_commands(
487
+ self,
488
+ payload: bytes,
489
+ dev_id: int,
490
+ *,
491
+ hub_version: str,
492
+ count: int | None = None,
493
+ ) -> Dict[int, str]:
494
+ """Parse an assembled REQ_COMMANDS body into a ``{command_id: label}``.
495
+
496
+ Uses the assembled fixed-stride schema parser
497
+ :func:`commands.iter_command_records_from_assembled`
498
+
499
+
500
+
501
+ ``count`` may be supplied explicitly (e.g. from the page-1 header's
502
+ ``total_commands`` field). If omitted, it is inferred from
503
+ ``len(payload) // stride`` — correct for well-formed real wire data
504
+ (always a clean multiple of the stride) and graceful for slightly
505
+ malformed inputs because the parser silently stops at truncated
506
+ records.
507
+ """
508
+
509
+ stride = (
510
+ COMMAND_RECORD_STRIDE_X1
511
+ if hub_version == HUB_VERSION_X1
512
+ else COMMAND_RECORD_STRIDE_X1S_X2
513
+ )
514
+ effective_count = count if count is not None else len(payload) // stride
515
+
516
+ commands_found: Dict[int, str] = {}
517
+ for record in iter_command_records_from_assembled(
518
+ payload,
519
+ count=effective_count,
520
+ dev_id=dev_id,
521
+ hub_version=hub_version,
522
+ ):
523
+ # control[0] is the codec selector; control[1..7] is the
524
+ # 6-byte canonical button code (BE). Surface both into the
525
+ # per-command metadata cache so backup can capture them
526
+ # without re-fetching the records.
527
+ if len(record.control) >= 7:
528
+ self.command_metadata[dev_id & 0xFF][record.command_id & 0xFF] = {
529
+ "library_type": record.control[0] & 0xFF,
530
+ "button_code": int.from_bytes(record.control[1:7], "big"),
531
+ "sort_id": record.sort_id & 0xFF,
532
+ }
533
+ if record.command_id not in commands_found and record.label:
534
+ commands_found[record.command_id] = record.label
535
+ return commands_found
536
+
537
+ def record_virtual_device(
538
+ self,
539
+ device_id: int,
540
+ *,
541
+ name: str,
542
+ button_id: int | None = None,
543
+ method: str | None = None,
544
+ url: str | None = None,
545
+ headers: dict[str, str] | None = None,
546
+ button_name: str | None = None,
547
+ ) -> None:
548
+ brand = "Virtual HTTP"
549
+ self.devices[device_id & 0xFF] = normalize_device_entry(
550
+ {
551
+ **(self.devices.get(device_id & 0xFF, {})),
552
+ "brand": brand,
553
+ "name": name,
554
+ },
555
+ default_class=DEVICE_CLASS_WIFI_IP,
556
+ default_class_code=0x1C,
557
+ )
558
+ if button_id is not None:
559
+ self.buttons.setdefault(device_id & 0xFF, set()).add(button_id)
560
+ meta: Dict[str, Any] = {
561
+ "device_id": device_id & 0xFF,
562
+ "name": name,
563
+ "brand": brand,
564
+ }
565
+ if method is not None:
566
+ meta["method"] = method
567
+ if url is not None:
568
+ meta["url"] = url
569
+ if headers is not None:
570
+ meta["headers"] = headers
571
+ if button_name is not None:
572
+ meta["button_name"] = button_name
573
+ if button_id is not None:
574
+ self.ip_buttons[device_id & 0xFF][button_id] = meta
575
+ self.ip_devices[device_id & 0xFF] = meta
576
+
577
+ def record_app_activation(
578
+ self,
579
+ *,
580
+ ent_id: int,
581
+ ent_kind: str,
582
+ ent_name: str,
583
+ command_id: int,
584
+ command_label: str | None,
585
+ button_label: str | None,
586
+ direction: str,
587
+ ts: Optional[float] = None,
588
+ ) -> dict[str, Any]:
589
+ timestamp = ts if ts is not None else time.time()
590
+ record = {
591
+ "timestamp": timestamp,
592
+ "iso_time": time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(timestamp)),
593
+ "direction": direction,
594
+ "entity_id": ent_id,
595
+ "entity_kind": ent_kind,
596
+ "entity_name": ent_name,
597
+ "command_id": command_id,
598
+ "command_label": command_label,
599
+ "button_label": button_label,
600
+ }
601
+ self.app_activations.append(record)
602
+ return record
603
+
604
+ def get_app_activations(self) -> list[dict[str, Any]]:
605
+ return list(self.app_activations)
606
+
607
+
608
+ class BurstScheduler:
609
+ def __init__(self, *, idle_s: float = 0.15, response_grace: float = 1.0) -> None:
610
+ self.idle_s = idle_s
611
+ self.response_grace = response_grace
612
+ self.active = False
613
+ self.kind: str | None = None
614
+ self.last_ts = 0.0
615
+ self.queue: list[tuple[int, bytes, bool, Optional[str]]] = []
616
+ self.listeners: dict[str, list[Callable[[str], None]]] = {}
617
+
618
+ def on_burst_end(self, key: str, cb: Callable[[str], None]) -> None:
619
+ self.listeners.setdefault(key, []).append(cb)
620
+
621
+ def start(self, kind: str, *, now: Optional[float] = None) -> None:
622
+ self.active = True
623
+ self.kind = kind
624
+ base = time.monotonic() if now is None else now
625
+ self.last_ts = base + self.response_grace
626
+
627
+ def queue_or_send(
628
+ self,
629
+ *,
630
+ opcode: int,
631
+ payload: bytes,
632
+ expects_burst: bool,
633
+ burst_kind: Optional[str],
634
+ can_issue: Callable[[], bool],
635
+ sender: Callable[[int, bytes], None],
636
+ now: Optional[float] = None,
637
+ ) -> bool:
638
+ is_burst = expects_burst
639
+ current_time = time.monotonic() if now is None else now
640
+
641
+ if not can_issue():
642
+ return False
643
+
644
+ if self.active:
645
+ self.queue.append((opcode, payload, is_burst, burst_kind))
646
+ return True
647
+
648
+ if is_burst:
649
+ self.start(burst_kind or "generic", now=current_time)
650
+
651
+ sender(opcode, payload)
652
+ return True
653
+
654
+ def tick(
655
+ self,
656
+ now: float,
657
+ *,
658
+ can_issue: Callable[[], bool],
659
+ sender: Callable[[int, bytes], None],
660
+ ) -> None:
661
+ if not self.active:
662
+ return
663
+ if now - self.last_ts < self.idle_s:
664
+ return
665
+ self._drain(can_issue=can_issue, sender=sender, now=now)
666
+
667
+ def finish(
668
+ self,
669
+ key: str,
670
+ *,
671
+ can_issue: Callable[[], bool],
672
+ sender: Callable[[int, bytes], None],
673
+ now: Optional[float] = None,
674
+ ) -> bool:
675
+ if not self.active or self.kind != key:
676
+ return False
677
+ self._drain(
678
+ can_issue=can_issue,
679
+ sender=sender,
680
+ now=time.monotonic() if now is None else now,
681
+ )
682
+ return True
683
+
684
+ def _drain(
685
+ self,
686
+ *,
687
+ can_issue: Callable[[], bool],
688
+ sender: Callable[[int, bytes], None],
689
+ now: float,
690
+ ) -> None:
691
+ finished_kind = self.kind or "generic"
692
+ self.active = False
693
+ self.kind = None
694
+ self._notify_burst_end(finished_kind)
695
+
696
+ while self.queue:
697
+ op, payload, is_burst, next_kind = self.queue.pop(0)
698
+ if not can_issue():
699
+ continue
700
+ if is_burst:
701
+ self.start(next_kind or "generic", now=now)
702
+ sender(op, payload)
703
+ if self.active:
704
+ break
705
+
706
+ def _notify_burst_end(self, key: str) -> None:
707
+ for cb in self.listeners.get(key, []):
708
+ cb(key)
709
+ if ":" in key:
710
+ prefix = key.split(":", 1)[0]
711
+ for cb in self.listeners.get(prefix, []):
712
+ cb(key)
713
+