bdo-toolkit 1.0.6__tar.gz → 1.0.7__tar.gz

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.
Files changed (163) hide show
  1. {bdo_toolkit-1.0.6/src/bdo_toolkit.egg-info → bdo_toolkit-1.0.7}/PKG-INFO +6 -4
  2. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/README.md +5 -3
  3. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/__init__.py +3 -1
  4. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_engine.py +18 -2
  5. bdo_toolkit-1.0.6/src/bdo_toolkit/agris/session.py → bdo_toolkit-1.0.7/src/bdo_toolkit/_observation_session.py +61 -71
  6. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_profile_runtime.py +10 -0
  7. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_reassembly.py +18 -11
  8. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_version.py +1 -1
  9. bdo_toolkit-1.0.7/src/bdo_toolkit/agris/__init__.py +17 -0
  10. bdo_toolkit-1.0.7/src/bdo_toolkit/agris/_calibration_models.py +70 -0
  11. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/agris/_discovery.py +28 -0
  12. bdo_toolkit-1.0.7/src/bdo_toolkit/agris/_profile.py +17 -0
  13. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/agris/async_session.py +14 -2
  14. bdo_toolkit-1.0.7/src/bdo_toolkit/agris/calibration.py +116 -0
  15. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/agris/replay.py +10 -5
  16. bdo_toolkit-1.0.7/src/bdo_toolkit/agris/session.py +75 -0
  17. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/capture.py +21 -2
  18. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/cli.py +41 -8
  19. bdo_toolkit-1.0.7/src/bdo_toolkit/grind/__init__.py +5 -0
  20. bdo_toolkit-1.0.7/src/bdo_toolkit/grind/_pipeline.py +127 -0
  21. bdo_toolkit-1.0.7/src/bdo_toolkit/grind/models.py +71 -0
  22. bdo_toolkit-1.0.7/src/bdo_toolkit/grind/session.py +54 -0
  23. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/profiles.py +120 -1
  24. bdo_toolkit-1.0.7/src/bdo_toolkit/xp/__init__.py +13 -0
  25. bdo_toolkit-1.0.7/src/bdo_toolkit/xp/_tracker.py +109 -0
  26. bdo_toolkit-1.0.7/src/bdo_toolkit/xp/models.py +77 -0
  27. bdo_toolkit-1.0.7/src/bdo_toolkit/xp/profile.py +54 -0
  28. bdo_toolkit-1.0.7/src/bdo_toolkit/xp/replay.py +104 -0
  29. bdo_toolkit-1.0.7/src/bdo_toolkit/xp/session.py +56 -0
  30. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7/src/bdo_toolkit.egg-info}/PKG-INFO +6 -4
  31. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit.egg-info/SOURCES.txt +19 -1
  32. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_agris_async.py +1 -0
  33. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_agris_evidence.py +16 -0
  34. bdo_toolkit-1.0.7/tests/test_agris_profiles.py +340 -0
  35. bdo_toolkit-1.0.7/tests/test_grind.py +534 -0
  36. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_reassembly.py +70 -0
  37. bdo_toolkit-1.0.7/tests/test_xp.py +229 -0
  38. bdo_toolkit-1.0.7/tests/test_xp_profiles.py +72 -0
  39. bdo_toolkit-1.0.6/src/bdo_toolkit/agris/__init__.py +0 -16
  40. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/LICENSE +0 -0
  41. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/pyproject.toml +0 -0
  42. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/setup.cfg +0 -0
  43. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_async_sessions.py +0 -0
  44. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_async_utils.py +0 -0
  45. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/__init__.py +0 -0
  46. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/_constants.py +0 -0
  47. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/_formatting.py +0 -0
  48. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/_records.py +0 -0
  49. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/analysis.py +0 -0
  50. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/capture.py +0 -0
  51. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/companions.py +0 -0
  52. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/live.py +0 -0
  53. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/models.py +0 -0
  54. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/observations.py +0 -0
  55. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/persistence.py +0 -0
  56. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/progress.py +0 -0
  57. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/validation.py +0 -0
  58. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/workflow.py +0 -0
  59. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_capture_backend.py +0 -0
  60. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_capture_options.py +0 -0
  61. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_capture_runtime.py +0 -0
  62. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_deposit_origin.py +0 -0
  63. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_framing.py +0 -0
  64. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/__init__.py +0 -0
  65. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/_constants.py +0 -0
  66. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/_records.py +0 -0
  67. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/assembly.py +0 -0
  68. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/formatting.py +0 -0
  69. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/inventory.py +0 -0
  70. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/models.py +0 -0
  71. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/session.py +0 -0
  72. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/storage.py +0 -0
  73. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_origin/__init__.py +0 -0
  74. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_origin/discovery.py +0 -0
  75. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_origin/manual.py +0 -0
  76. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_origin/models.py +0 -0
  77. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_origin/tracker.py +0 -0
  78. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_profile_io.py +0 -0
  79. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_protocol.py +0 -0
  80. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_record_geometry.py +0 -0
  81. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_specs.py +0 -0
  82. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_storage_destination_validation.py +0 -0
  83. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_storage_hydration.py +0 -0
  84. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/agris/_capture.py +0 -0
  85. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/agris/_validation.py +0 -0
  86. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/agris/models.py +0 -0
  87. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/calibration.py +0 -0
  88. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/capture_diagnosis.py +0 -0
  89. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/character_state.py +0 -0
  90. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/diagnostics.py +0 -0
  91. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/events.py +0 -0
  92. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/filters.py +0 -0
  93. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/item_state.py +0 -0
  94. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/origin_learning.py +0 -0
  95. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/py.typed +0 -0
  96. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/remote_profiles.py +0 -0
  97. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/__init__.py +0 -0
  98. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_constants.py +0 -0
  99. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_detail_learning.py +0 -0
  100. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_details.py +0 -0
  101. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_discovery.py +0 -0
  102. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_live_tracker.py +0 -0
  103. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_replay_capture.py +0 -0
  104. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_result.py +0 -0
  105. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_scanner.py +0 -0
  106. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_validation.py +0 -0
  107. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/async_session.py +0 -0
  108. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/models.py +0 -0
  109. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/replay.py +0 -0
  110. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/session.py +0 -0
  111. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/writers.py +0 -0
  112. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit.egg-info/dependency_links.txt +0 -0
  113. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit.egg-info/entry_points.txt +0 -0
  114. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit.egg-info/requires.txt +0 -0
  115. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit.egg-info/top_level.txt +0 -0
  116. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_agris_cli.py +0 -0
  117. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_agris_discovery.py +0 -0
  118. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_agris_models.py +0 -0
  119. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_agris_replay.py +0 -0
  120. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_agris_session.py +0 -0
  121. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_async_sessions.py +0 -0
  122. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_calibration.py +0 -0
  123. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_calibration_conveniences.py +0 -0
  124. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_calibration_lifecycle.py +0 -0
  125. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_calibration_persistence.py +0 -0
  126. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_calibration_progress.py +0 -0
  127. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_calibration_retention.py +0 -0
  128. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_capture_backend.py +0 -0
  129. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_capture_diagnosis.py +0 -0
  130. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_capture_replay.py +0 -0
  131. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_capture_runtime.py +0 -0
  132. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_character_state.py +0 -0
  133. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_cli.py +0 -0
  134. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_decoder_diagnostics.py +0 -0
  135. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_deposit_origin.py +0 -0
  136. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_direction.py +0 -0
  137. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_dynamic_storage_calibration_matrix.py +0 -0
  138. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_engine.py +0 -0
  139. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_examples_and_writers.py +0 -0
  140. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_explicit_profile.py +0 -0
  141. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_fixture_catalog.py +0 -0
  142. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_fixture_regressions.py +0 -0
  143. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_framing.py +0 -0
  144. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_initial_load_storage.py +0 -0
  145. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_inventory_snapshots.py +0 -0
  146. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_item_state.py +0 -0
  147. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_live_capture_session.py +0 -0
  148. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_origin_learning.py +0 -0
  149. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_package_layout.py +0 -0
  150. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_profile_validation.py +0 -0
  151. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_remote_profiles.py +0 -0
  152. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_solare_api.py +0 -0
  153. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_solare_details.py +0 -0
  154. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_solare_learning.py +0 -0
  155. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_solare_learning_adversarial.py +0 -0
  156. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_solare_real_captures.py +0 -0
  157. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_solare_replay.py +0 -0
  158. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_solare_session.py +0 -0
  159. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_storage_destination_validation.py +0 -0
  160. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_storage_events.py +0 -0
  161. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_storage_hydration_tracker.py +0 -0
  162. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_storage_unknown_destination_calibration.py +0 -0
  163. {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_toolkit_api.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bdo-toolkit
3
- Version: 1.0.6
3
+ Version: 1.0.7
4
4
  Summary: Passive, read-only BDO packet parsing toolkit for app developers.
5
5
  Author: bdo-toolkit contributors
6
6
  License-Expression: MIT
@@ -60,16 +60,18 @@ no usable adapter is found. Discovery does not apply settings or verify decoding
60
60
 
61
61
  ## Capabilities
62
62
 
63
- bdo-toolkit exposes three passive workflows. Each can observe live traffic or
64
- replay a saved PCAP or PCAPNG file.
63
+ bdo-toolkit exposes four passive workflows. Item activity, item state, and
64
+ Solare support live capture and saved PCAP or PCAPNG replay. Grind tracking
65
+ combines supported observations in one live session.
65
66
 
66
67
  | Capability | What it provides | Guide | Status |
67
68
  | --- | --- | --- | --- |
68
69
  | Item activity | A continuing stream of typed `BDOEvent` objects for supported loot, gathering, inventory, and storage changes | [Item events](https://ychwu.github.io/bdo-toolkit/#item-overview) | Stable |
69
70
  | Inventory and town storage | A finite `ItemStateSnapshot` assembled from character-load traffic, with inventory, known balances, and observed town storage | [Inventory & town storage](https://ychwu.github.io/bdo-toolkit/#item-state-overview) | Beta |
70
71
  | Arena of Solare leaderboards | A finite `SolareCaptureResult` containing overall rankings, class tables, and player statistics when the capture is complete | [Arena of Solare](https://ychwu.github.io/bdo-toolkit/#solare-overview) | Beta |
72
+ | Grind tracking | One capture for confirmed mob drops, with optional profile-based Agris and solo XP | [GrindSession](https://ychwu.github.io/bdo-toolkit/#grind-session) | Experimental |
71
73
 
72
- These workflows include synchronous and
74
+ The standalone workflows include synchronous and
73
75
  [asyncio](https://ychwu.github.io/bdo-toolkit/#asyncio) sessions, capture and
74
76
  decoder health diagnostics, console and JSONL event writers, and a
75
77
  [command-line interface](https://ychwu.github.io/bdo-toolkit/#cli). Exact
@@ -33,16 +33,18 @@ no usable adapter is found. Discovery does not apply settings or verify decoding
33
33
 
34
34
  ## Capabilities
35
35
 
36
- bdo-toolkit exposes three passive workflows. Each can observe live traffic or
37
- replay a saved PCAP or PCAPNG file.
36
+ bdo-toolkit exposes four passive workflows. Item activity, item state, and
37
+ Solare support live capture and saved PCAP or PCAPNG replay. Grind tracking
38
+ combines supported observations in one live session.
38
39
 
39
40
  | Capability | What it provides | Guide | Status |
40
41
  | --- | --- | --- | --- |
41
42
  | Item activity | A continuing stream of typed `BDOEvent` objects for supported loot, gathering, inventory, and storage changes | [Item events](https://ychwu.github.io/bdo-toolkit/#item-overview) | Stable |
42
43
  | Inventory and town storage | A finite `ItemStateSnapshot` assembled from character-load traffic, with inventory, known balances, and observed town storage | [Inventory & town storage](https://ychwu.github.io/bdo-toolkit/#item-state-overview) | Beta |
43
44
  | Arena of Solare leaderboards | A finite `SolareCaptureResult` containing overall rankings, class tables, and player statistics when the capture is complete | [Arena of Solare](https://ychwu.github.io/bdo-toolkit/#solare-overview) | Beta |
45
+ | Grind tracking | One capture for confirmed mob drops, with optional profile-based Agris and solo XP | [GrindSession](https://ychwu.github.io/bdo-toolkit/#grind-session) | Experimental |
44
46
 
45
- These workflows include synchronous and
47
+ The standalone workflows include synchronous and
46
48
  [asyncio](https://ychwu.github.io/bdo-toolkit/#asyncio) sessions, capture and
47
49
  decoder health diagnostics, console and JSONL event writers, and a
48
50
  [command-line interface](https://ychwu.github.io/bdo-toolkit/#cli). Exact
@@ -41,7 +41,7 @@ from .origin_learning import (
41
41
  promote_origin_candidates,
42
42
  )
43
43
  from .writers import ConsoleEventWriter, JsonlEventWriter
44
- from . import agris, solare
44
+ from . import agris, grind, solare, xp
45
45
  from ._version import __version__
46
46
 
47
47
  __all__ = [
@@ -84,10 +84,12 @@ __all__ = [
84
84
  "__version__",
85
85
  "capture_live",
86
86
  "agris",
87
+ "grind",
87
88
  "fetch_opcode_profile",
88
89
  "load_opcode_profile",
89
90
  "promote_origin_candidates",
90
91
  "replay_pcap",
91
92
  "storage_location",
92
93
  "solare",
94
+ "xp",
93
95
  ]
@@ -41,16 +41,20 @@ class _TeeScanner:
41
41
  primary: TargetMessageScanner,
42
42
  tap: Optional[FrameCollectorScanner],
43
43
  stream_observer: Optional[Callable[[bytes, PacketContext], None]],
44
+ unhinted_tap: Optional[FrameCollectorScanner] = None,
44
45
  ) -> None:
45
46
  self._primary = primary
46
47
  self._tap = tap
47
48
  self._stream_observer = stream_observer
49
+ self._unhinted_tap = unhinted_tap
48
50
 
49
51
  def feed(self, data, context) -> None:
50
52
  if self._stream_observer is not None:
51
53
  self._stream_observer(data, context)
52
54
  if self._tap is not None:
53
55
  self._tap.feed(data, context)
56
+ if self._unhinted_tap is not None:
57
+ self._unhinted_tap.feed(data, context)
54
58
  self._primary.feed(data, context)
55
59
 
56
60
  def scan_standalone(self, data, context) -> None:
@@ -58,6 +62,8 @@ class _TeeScanner:
58
62
  self._stream_observer(data, context)
59
63
  if self._tap is not None:
60
64
  self._tap.scan_standalone(data, context)
65
+ if self._unhinted_tap is not None:
66
+ self._unhinted_tap.scan_standalone(data, context)
61
67
  self._primary.scan_standalone(data, context)
62
68
 
63
69
  def can_anchor_at_start(self, data: bytes) -> bool:
@@ -69,6 +75,8 @@ class _TeeScanner:
69
75
  def reset(self) -> None:
70
76
  if self._tap is not None:
71
77
  self._tap.reset()
78
+ if self._unhinted_tap is not None:
79
+ self._unhinted_tap.reset()
72
80
  self._primary.reset()
73
81
 
74
82
 
@@ -189,6 +197,8 @@ class PacketEngine:
189
197
  message_observer: Optional[MessageObserver] = None,
190
198
  max_pending_segments: int = MAX_PENDING_SEGMENTS,
191
199
  max_pending_bytes: Optional[int] = None,
200
+ defer_gap_timeouts: bool = False,
201
+ unhinted_frame_observer: Optional[Callable[[BDOFrame], None]] = None,
192
202
  ) -> None:
193
203
  self.event_specs = tuple(event_specs)
194
204
  self.events_found = 0
@@ -203,7 +213,7 @@ class PacketEngine:
203
213
  self.event_specs,
204
214
  message_observer=message_observer,
205
215
  )
206
- if frame_observer is None and stream_observer is None:
216
+ if frame_observer is None and stream_observer is None and unhinted_frame_observer is None:
207
217
  return primary
208
218
  tap = (
209
219
  FrameCollectorScanner(
@@ -213,7 +223,12 @@ class PacketEngine:
213
223
  if frame_observer is not None
214
224
  else None
215
225
  )
216
- return _TeeScanner(primary, tap, stream_observer)
226
+ # Optional telemetry must keep its standalone synchronization
227
+ # authority. Item opcode hints are only for the existing item tap;
228
+ # accidental headers in a midstream prefix must not steer XP/Agris.
229
+ unhinted_tap = (FrameCollectorScanner(unhinted_frame_observer)
230
+ if unhinted_frame_observer is not None else None)
231
+ return _TeeScanner(primary, tap, stream_observer, unhinted_tap)
217
232
 
218
233
  self._flow_manager = FlowManager(
219
234
  server_ports=server_ports,
@@ -226,6 +241,7 @@ class PacketEngine:
226
241
  idle_timeout=_ITEM_FLOW_IDLE_SECONDS,
227
242
  max_pending_segments=max_pending_segments,
228
243
  max_pending_bytes=max_pending_bytes,
244
+ defer_gap_timeouts=defer_gap_timeouts,
229
245
  )
230
246
  self._seen_event_keys: set[
231
247
  tuple[FlowKey, int, int, int, Optional[int], bytes]
@@ -1,42 +1,45 @@
1
- """Single-owner live Agris acquisition; no profiles or consumption accounting."""
1
+ """Private bounded observation-session lifecycle shared by Agris and solo XP."""
2
2
  from __future__ import annotations
3
3
 
4
4
  from collections.abc import Iterator
5
- from dataclasses import replace
6
5
  import math
7
6
  from pathlib import Path
8
7
  from queue import Empty, Full, Queue
9
8
  from threading import Event, Lock, Thread, current_thread
10
9
  import time
11
10
  from types import TracebackType
12
- from typing import Any, Callable
11
+ from typing import Any, Callable, Protocol, Self
13
12
 
14
- from .._capture_backend import make_packet_handler, open_packet_writer
15
- from .._capture_options import LiveCaptureOptions
16
- from .._capture_runtime import CaptureEndpoint, CaptureStats, LivePacketCapture, _attach_cleanup_owner
17
- from ..capture import CaptureIntegrityError
18
- from ._capture import AgrisCaptureHealth, AgrisFlowManager
19
- from ._discovery import AgrisTracker
20
- from .models import AgrisBalance, AgrisDiscoveryOptions, AgrisStatus
13
+ from ._capture_options import LiveCaptureOptions
14
+ from ._capture_runtime import CaptureEndpoint, CaptureStats, LivePacketCapture, _attach_cleanup_owner
15
+ from ._capture_backend import SegmentConsumer
16
+ from .capture import CaptureIntegrityError
21
17
 
22
18
 
23
- class LiveAgrisSession:
24
- """Continuous, single-consumer Agris balance stream with required cap input.
19
+ class ObservationManager(SegmentConsumer, Protocol):
20
+ @property
21
+ def tcp_gap_resets(self) -> int: ...
22
+
23
+ def service_gaps(self, now: float) -> int: ...
24
+
25
25
 
26
- One owner thread starts/stops acquisition, decodes packets, and services
27
- deadlines even if the application stops polling. Native callbacks only
28
- enqueue packets. Both queues are bounded: overflow is an explicit failure.
29
- Every session cold-discovers an inferred layout; none is saved or reused.
26
+ class ObservationSession[T, S]:
27
+ """Single owner for acquisition, bounded delivery, deadlines and cleanup.
28
+
29
+ Domains own their decoder, status/health view and clean-stop result policy.
30
+ Factories are explicit so existing domain test seams remain unchanged.
30
31
  """
31
32
 
32
33
  _JOIN_TIMEOUT = 15.0
33
34
  _START_TIMEOUT = 15.0
34
35
 
35
- def __init__(self, *, expected_maximum_points: int,
36
- live_options: LiveCaptureOptions | None = None,
37
- discovery_options: AgrisDiscoveryOptions | None = None,
38
- capture_seconds: float | None = None,
39
- save_pcap: str | Path | None = None) -> None:
36
+ def __init__(self, *, label: str, snapshot: Callable[[], S],
37
+ manager: ObservationManager, refresh: Callable[[float | None], None],
38
+ live_options: LiveCaptureOptions | None,
39
+ capture_seconds: float | None, save_pcap: str | Path | None,
40
+ capture_factory: Callable[..., LivePacketCapture],
41
+ handler_factory: Callable[..., Callable[[object], None]],
42
+ writer_factory: Callable[..., Any]) -> None:
40
43
  if live_options is not None and not isinstance(live_options, LiveCaptureOptions):
41
44
  raise TypeError("live_options must be LiveCaptureOptions or None")
42
45
  if capture_seconds is not None and (isinstance(capture_seconds, bool)
@@ -44,12 +47,12 @@ class LiveAgrisSession:
44
47
  or not math.isfinite(capture_seconds) or capture_seconds <= 0):
45
48
  raise ValueError("capture_seconds must be finite and positive")
46
49
  self._options = live_options or LiveCaptureOptions()
47
- self._events: Queue[AgrisBalance] = Queue(self._options.event_queue_size)
50
+ self._label, self._snapshot, self._refresh = label, snapshot, refresh
51
+ self._capture_factory, self._handler_factory, self._writer_factory = capture_factory, handler_factory, writer_factory
52
+ self._events: Queue[T] = Queue(self._options.event_queue_size)
48
53
  self._packets: Queue[object] = Queue(self._options.packet_queue_size)
49
- self._tracker = AgrisTracker(expected_maximum_points=expected_maximum_points,
50
- options=discovery_options, on_balance=self._publish)
51
- self._manager = AgrisFlowManager(self._tracker, self._options.ports, live=True)
52
- self._status = self._tracker.snapshot()
54
+ self._manager = manager
55
+ self._status = snapshot()
53
56
  self._seconds = capture_seconds
54
57
  self._save_path = Path(save_pcap).expanduser().resolve() if save_pcap is not None else None
55
58
  if self._save_path is not None and self._save_path.suffix.lower() not in {".pcap", ".pcapng"}:
@@ -73,23 +76,8 @@ class LiveAgrisSession:
73
76
  self._stop_reason: str | None = None
74
77
  self._accepted = self._processed = self._packet_overflows = self._event_overflows = 0
75
78
 
76
- @property
77
- def status(self) -> AgrisStatus:
78
- with self._state_lock:
79
- status, error = self._status, self._error
80
- return replace(status, status="invalid", balance=None, reason=str(error)) if error else status
81
-
82
- @property
83
- def health(self) -> AgrisCaptureHealth:
84
- with self._state_lock:
85
- return AgrisCaptureHealth(
86
- packets_processed=self._processed, packets_accepted=self._accepted,
87
- packet_queue_overflows=self._packet_overflows, event_queue_overflows=self._event_overflows,
88
- tcp_gap_resets=self._manager.tcp_gap_resets, flow_state_evictions=self._manager.evictions,
89
- pcap_received=self._stats.received, pcap_dropped=self._stats.dropped,
90
- pcap_interface_dropped=self._stats.interface_dropped,
91
- capture_buffer_bytes=self._stats.capture_buffer_bytes,
92
- cleanup_incomplete=self._cleanup_incomplete)
79
+ def _on_clean_stop(self) -> None:
80
+ pass
93
81
 
94
82
  @property
95
83
  def endpoint(self) -> CaptureEndpoint | None:
@@ -138,22 +126,22 @@ class LiveAgrisSession:
138
126
  except Full:
139
127
  with self._state_lock:
140
128
  self._packet_overflows += 1
141
- error = CaptureIntegrityError("Agris packet queue overflow; capture incomplete")
129
+ error = CaptureIntegrityError(f"{self._label} packet queue overflow; capture incomplete")
142
130
  self._record_error(error)
143
131
  raise error
144
132
  with self._state_lock:
145
133
  self._accepted += 1
146
134
 
147
- def _publish(self, balance: AgrisBalance) -> None:
135
+ def _publish(self, balance: T) -> None:
148
136
  try:
149
137
  self._events.put_nowait(balance)
150
138
  except Full as exc:
151
139
  with self._state_lock:
152
140
  self._event_overflows += 1
153
- raise CaptureIntegrityError("Agris balance queue overflow; consumer too slow") from exc
141
+ raise CaptureIntegrityError(f"{self._label} balance queue overflow; consumer too slow") from exc
154
142
 
155
143
  def _update_status(self) -> None:
156
- status = self._tracker.snapshot()
144
+ status = self._snapshot()
157
145
  with self._state_lock:
158
146
  self._status = status
159
147
 
@@ -165,14 +153,14 @@ class LiveAgrisSession:
165
153
  with self._state_lock:
166
154
  self._stats = stats
167
155
  if (stats.dropped or 0) > 0 or (stats.interface_dropped or 0) > 0:
168
- raise CaptureIntegrityError("Agris capture backend reported dropped packets")
156
+ raise CaptureIntegrityError(f"{self._label} capture backend reported dropped packets")
169
157
 
170
158
  def start(self) -> None:
171
159
  with self._lifecycle_lock:
172
160
  if self._start_attempted:
173
- raise RuntimeError("Agris sessions are single-use")
161
+ raise RuntimeError(f"{self._label} sessions are single-use")
174
162
  self._start_attempted = True
175
- worker = Thread(target=self._run, name="bdo-toolkit-agris", daemon=True)
163
+ worker = Thread(target=self._run, name=f"bdo-toolkit-{self._label.lower()}", daemon=True)
176
164
  self._worker = worker
177
165
  try:
178
166
  worker.start()
@@ -183,7 +171,7 @@ class LiveAgrisSession:
183
171
  raise
184
172
  try:
185
173
  if not self._ready.wait(self._START_TIMEOUT):
186
- raise TimeoutError("Agris capture startup did not become ready")
174
+ raise TimeoutError(f"{self._label} capture startup did not become ready")
187
175
  self.raise_if_failed()
188
176
  except BaseException as primary:
189
177
  self._record_error(primary)
@@ -192,9 +180,9 @@ class LiveAgrisSession:
192
180
  self.stop()
193
181
  except BaseException as cleanup:
194
182
  if cleanup is not primary:
195
- primary.add_note(f"Agris startup cleanup also failed: {cleanup!r}")
183
+ primary.add_note(f"{self._label} startup cleanup also failed: {cleanup!r}")
196
184
  if self.cleanup_incomplete:
197
- _attach_cleanup_owner(primary, self, context="Agris startup")
185
+ _attach_cleanup_owner(primary, self, context=f"{self._label} startup")
198
186
  raise
199
187
 
200
188
  def _process(self, packet: object) -> None:
@@ -204,7 +192,7 @@ class LiveAgrisSession:
204
192
  if self.error is None:
205
193
  assert self._handler is not None
206
194
  self._handler(packet)
207
- self._manager.refresh()
195
+ self._refresh(None)
208
196
  self._update_status()
209
197
  with self._state_lock:
210
198
  self._processed += 1
@@ -215,9 +203,9 @@ class LiveAgrisSession:
215
203
  if self._save_path.exists():
216
204
  raise FileExistsError(f"refusing to overwrite existing capture: {self._save_path}")
217
205
  self._save_path.parent.mkdir(parents=True, exist_ok=True)
218
- self._writer = open_packet_writer(self._save_path)
219
- self._handler = make_packet_handler(self._manager)
220
- self._capture = LivePacketCapture(capture_options=self._options, on_packet=self._accept)
206
+ self._writer = self._writer_factory(self._save_path)
207
+ self._handler = self._handler_factory(self._manager)
208
+ self._capture = self._capture_factory(capture_options=self._options, on_packet=self._accept)
221
209
  self._capture.start()
222
210
  self._capture_started = True
223
211
  self._capture.raise_if_failed()
@@ -227,7 +215,7 @@ class LiveAgrisSession:
227
215
  while not self._stop_requested.is_set():
228
216
  self._capture.raise_if_failed()
229
217
  if not self._capture.running:
230
- raise CaptureIntegrityError("Agris capture backend stopped unexpectedly")
218
+ raise CaptureIntegrityError(f"{self._label} capture backend stopped unexpectedly")
231
219
  if deadline is not None and time.monotonic() >= deadline:
232
220
  with self._state_lock:
233
221
  self._stop_reason = "duration"
@@ -243,7 +231,7 @@ class LiveAgrisSession:
243
231
  if self._packets.empty():
244
232
  now = time.time()
245
233
  self._manager.service_gaps(now)
246
- self._manager.refresh(now)
234
+ self._refresh(now)
247
235
  self._update_status()
248
236
  else:
249
237
  self._process(packet)
@@ -271,7 +259,7 @@ class LiveAgrisSession:
271
259
  if incomplete:
272
260
  with self._state_lock:
273
261
  self._cleanup_incomplete = True
274
- self._record_error(RuntimeError("Agris native cleanup is incomplete; retry stop()"))
262
+ self._record_error(RuntimeError(f"{self._label} native cleanup is incomplete; retry stop()"))
275
263
  return
276
264
  try:
277
265
  self._read_stats(final=True)
@@ -304,13 +292,15 @@ class LiveAgrisSession:
304
292
  with self._state_lock:
305
293
  self._cleanup_incomplete = False
306
294
  self._stop_reason = self._stop_reason or "requested"
295
+ if self.error is None:
296
+ self._on_clean_stop()
307
297
  self._stopped.set()
308
298
 
309
299
  def stop(self) -> None:
310
300
  if not self._start_attempted:
311
- raise RuntimeError("Agris session was not started")
301
+ raise RuntimeError(f"{self._label} session was not started")
312
302
  if self._worker is current_thread():
313
- raise RuntimeError("Agris stop() cannot join its decoder thread")
303
+ raise RuntimeError(f"{self._label} stop() cannot join its decoder thread")
314
304
  self._stop_requested.set()
315
305
  with self._lifecycle_lock:
316
306
  worker = self._worker
@@ -319,23 +309,23 @@ class LiveAgrisSession:
319
309
  if worker is not None and worker.is_alive():
320
310
  with self._state_lock:
321
311
  self._cleanup_incomplete = True
322
- self._record_error(RuntimeError("Agris decoder cleanup timed out; retry stop()"))
312
+ self._record_error(RuntimeError(f"{self._label} decoder cleanup timed out; retry stop()"))
323
313
  elif self.cleanup_incomplete:
324
314
  self._shutdown()
325
315
  error = self.error
326
316
  if error is not None:
327
317
  if self.cleanup_incomplete:
328
- _attach_cleanup_owner(error, self, context="Agris stop")
318
+ _attach_cleanup_owner(error, self, context=f"{self._label} stop")
329
319
  raise error
330
320
 
331
- def poll(self, timeout: float | None = None) -> AgrisBalance | None:
321
+ def poll(self, timeout: float | None = None) -> T | None:
332
322
  if timeout is not None and (isinstance(timeout, bool) or not isinstance(timeout, (float, int))
333
323
  or not math.isfinite(timeout) or timeout < 0):
334
324
  raise ValueError("timeout must be finite and nonnegative")
335
325
  if not self._start_attempted:
336
- raise RuntimeError("Agris session was not started")
326
+ raise RuntimeError(f"{self._label} session was not started")
337
327
  if not self._consumer_lock.acquire(blocking=False):
338
- raise RuntimeError("Agris sessions support one balance consumer")
328
+ raise RuntimeError(f"{self._label} sessions support one balance consumer")
339
329
  try:
340
330
  deadline = None if timeout is None else time.monotonic() + timeout
341
331
  while True:
@@ -362,7 +352,7 @@ class LiveAgrisSession:
362
352
  finally:
363
353
  self._consumer_lock.release()
364
354
 
365
- def events(self) -> Iterator[AgrisBalance]:
355
+ def events(self) -> Iterator[T]:
366
356
  while True:
367
357
  balance = self.poll()
368
358
  if balance is not None:
@@ -370,7 +360,7 @@ class LiveAgrisSession:
370
360
  elif self.stopped:
371
361
  return
372
362
 
373
- def __enter__(self) -> LiveAgrisSession:
363
+ def __enter__(self) -> Self:
374
364
  self.start()
375
365
  return self
376
366
 
@@ -382,5 +372,5 @@ class LiveAgrisSession:
382
372
  if exc_value is None:
383
373
  raise
384
374
  if self.cleanup_incomplete:
385
- _attach_cleanup_owner(exc_value, self, context="Agris context")
386
- exc_value.add_note(f"Agris context cleanup also failed: {cleanup!r}")
375
+ _attach_cleanup_owner(exc_value, self, context=f"{self._label} context")
376
+ exc_value.add_note(f"{self._label} context cleanup also failed: {cleanup!r}")
@@ -14,6 +14,8 @@ from .profiles import (
14
14
  OpcodeProfile,
15
15
  OriginCompanionFamily,
16
16
  ProfileError,
17
+ AgrisProfileLayout,
18
+ XPProfileLayout,
17
19
  )
18
20
 
19
21
 
@@ -39,6 +41,14 @@ def validate_runtime_profile(profile: OpcodeProfile) -> RuntimeProfileValidation
39
41
  )
40
42
  if profile.active is not True:
41
43
  raise ProfileError(f"Opcode profile is inactive: {profile.path}")
44
+ if profile.xp is not None:
45
+ if not isinstance(profile.xp, XPProfileLayout):
46
+ raise ProfileError("xp must be an XPProfileLayout")
47
+ profile.xp.__post_init__()
48
+ if profile.agris is not None:
49
+ if not isinstance(profile.agris, AgrisProfileLayout):
50
+ raise ProfileError("agris must be an AgrisProfileLayout")
51
+ profile.agris.__post_init__()
42
52
 
43
53
  loaded_specs = event_specs_from_profile(profile)
44
54
  decrement_specs = tuple(
@@ -357,19 +357,23 @@ class TCPFlowState:
357
357
  and self.gap_started_at is not None
358
358
  and now - self.gap_started_at >= GAP_RESET_SECONDS
359
359
  ):
360
- fin_sequence = _unwrap_tcp_sequence(
361
- self.fin_sequence,
362
- self.next_sequence,
363
- )
364
- if self.next_sequence < fin_sequence:
365
- self.scanner.reset()
366
- if self.on_gap_reset is not None:
367
- self.on_gap_reset(fin_sequence)
368
- self.next_sequence = fin_sequence
369
- self.gap_started_at = None
370
- resets += 1
360
+ resets += self._resume_after_fin_gap()
371
361
  return resets
372
362
 
363
+ def _resume_after_fin_gap(self) -> bool:
364
+ """Report bytes proven missing by FIN, after pending payload is drained."""
365
+ if self.fin_sequence is None or self.next_sequence is None:
366
+ return False
367
+ fin_sequence = _unwrap_tcp_sequence(self.fin_sequence, self.next_sequence)
368
+ if self.next_sequence >= fin_sequence:
369
+ return False
370
+ self.scanner.reset()
371
+ if self.on_gap_reset is not None:
372
+ self.on_gap_reset(fin_sequence)
373
+ self.next_sequence = fin_sequence
374
+ self.gap_started_at = None
375
+ return True
376
+
373
377
  def _resume_after_gap(self) -> None:
374
378
  if not self.pending:
375
379
  return
@@ -394,6 +398,9 @@ class TCPFlowState:
394
398
  self._commit_unanchored()
395
399
  while self.pending:
396
400
  self._resume_after_gap()
401
+ # EOF alone cannot prove missing bytes. An observed FIN ahead of the
402
+ # drained stream can, even when shutdown precedes the live gap timer.
403
+ self._resume_after_fin_gap()
397
404
 
398
405
 
399
406
  class FlowManager:
@@ -1,3 +1,3 @@
1
1
  """Single source of truth for the BDO Toolkit release version."""
2
2
 
3
- __version__ = "1.0.6"
3
+ __version__ = "1.0.7"
@@ -0,0 +1,17 @@
1
+ """Passive Agris balances with explicit profile decoding or required-cap discovery."""
2
+ from ..profiles import AgrisProfileLayout
3
+ from ._calibration_models import AgrisCalibrationError, AgrisCalibrationResult, AgrisProfileUpdate
4
+ from ._capture import AgrisCaptureHealth
5
+ from .async_session import AsyncLiveAgrisSession
6
+ from .models import AgrisBalance, AgrisDetectionError, AgrisDiscoveryOptions, AgrisLayout, AgrisStatus
7
+ from .replay import AgrisReplay, replay_agris
8
+ from .session import LiveAgrisSession
9
+ from .calibration import calibrate_agris_live, calibrate_agris_and_update, update_agris_profile
10
+
11
+ __all__ = [
12
+ "AgrisBalance", "AgrisCaptureHealth", "AgrisDetectionError", "AgrisDiscoveryOptions",
13
+ "AgrisLayout", "AgrisReplay", "AgrisStatus", "AsyncLiveAgrisSession",
14
+ "LiveAgrisSession", "replay_agris",
15
+ "AgrisProfileLayout", "AgrisCalibrationError", "AgrisCalibrationResult", "AgrisProfileUpdate",
16
+ "calibrate_agris_live", "calibrate_agris_and_update", "update_agris_profile",
17
+ ]
@@ -0,0 +1,70 @@
1
+ """Final Agris calibration evidence and explicit profile-write outcomes."""
2
+ from dataclasses import dataclass
3
+ from datetime import UTC, datetime
4
+ from pathlib import Path
5
+
6
+ from ..profiles import AgrisProfileLayout
7
+ from ._capture import AgrisCaptureHealth
8
+ from ._validation import validate_expected_maximum_points
9
+ from .models import AgrisDetectionError, AgrisStatus
10
+
11
+
12
+ class AgrisCalibrationError(AgrisDetectionError):
13
+ """No final, clean discovered layout is available for persistence."""
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class AgrisCalibrationResult:
18
+ layout: AgrisProfileLayout
19
+ expected_maximum_points: int
20
+ observed_frames: int
21
+ health: AgrisCaptureHealth
22
+ completed_at: str
23
+
24
+ def __post_init__(self) -> None:
25
+ if not isinstance(self.layout, AgrisProfileLayout):
26
+ raise TypeError("layout must be AgrisProfileLayout")
27
+ self.layout.__post_init__()
28
+ validate_expected_maximum_points(self.expected_maximum_points)
29
+ if type(self.observed_frames) is not int or self.observed_frames < 3:
30
+ raise AgrisCalibrationError("Calibration requires at least three observed frames")
31
+ if not isinstance(self.health, AgrisCaptureHealth) or not self.health.capture_is_clean or self.health.cleanup_incomplete:
32
+ raise AgrisCalibrationError("Calibration requires clean, completed capture")
33
+ if not isinstance(self.completed_at, str):
34
+ raise TypeError("completed_at must be an ISO timestamp")
35
+ try:
36
+ if datetime.fromisoformat(self.completed_at).tzinfo is None:
37
+ raise ValueError
38
+ except ValueError as exc:
39
+ raise ValueError("completed_at must be a timezone-aware ISO timestamp") from exc
40
+
41
+ def to_dict(self) -> dict[str, object]:
42
+ return {"layout": self.layout.to_dict(), "expected_maximum_points": self.expected_maximum_points,
43
+ "observed_frames": self.observed_frames, "health": self.health.to_dict(),
44
+ "completed_at": self.completed_at}
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class AgrisProfileUpdate:
49
+ path: Path
50
+ written: bool
51
+ backup_path: Path | None
52
+ layout: AgrisProfileLayout
53
+
54
+ def to_dict(self) -> dict[str, object]:
55
+ return {"path": str(self.path), "written": self.written,
56
+ "backup_path": str(self.backup_path) if self.backup_path else None,
57
+ "layout": self.layout.to_dict()}
58
+
59
+
60
+ def final_calibration_result(status: AgrisStatus, health: AgrisCaptureHealth) -> AgrisCalibrationResult | None:
61
+ if status.status != "tracking" or status.balance is None or len(status.candidates) != 1:
62
+ return None
63
+ if not health.capture_is_clean or health.cleanup_incomplete:
64
+ return None
65
+ candidate = status.candidates[0]
66
+ layout = AgrisProfileLayout(opcode=candidate.opcode, message_length=candidate.message_length,
67
+ remaining_offset=candidate.remaining_offset, maximum_offset=candidate.maximum_offset,
68
+ observed_date=datetime.fromtimestamp(status.balance.observed_at, UTC).date().isoformat())
69
+ return AgrisCalibrationResult(layout, status.expected_maximum_points, status.observed_frames,
70
+ health, datetime.now(UTC).isoformat())