benchlab-pytools 3.1.0__tar.gz → 3.2.1__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 (208) hide show
  1. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/CHANGELOG.md +61 -0
  2. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/PKG-INFO +11 -4
  3. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/README.md +4 -0
  4. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/__init__.py +1 -1
  5. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/config/README.md +15 -0
  6. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/config/config_client.py +46 -0
  7. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/config/test_config_client.py +86 -0
  8. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/core/README.md +1 -1
  9. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/csv_log/requirements.txt +1 -1
  10. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/requirements.txt +1 -1
  11. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/restapi/readme.md +11 -0
  12. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/restapi/telemetry_api.py +148 -22
  13. benchlab_pytools-3.2.1/benchlab/restapi/test_read_recovery.py +235 -0
  14. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/restapi/test_server.py +1 -0
  15. benchlab_pytools-3.2.1/benchlab/tui/requirements.txt +4 -0
  16. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/tui/test_tui_core.py +23 -0
  17. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/tui/test_tui_main.py +37 -0
  18. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/tui/tui_core.py +2 -0
  19. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/tui/tui_main.py +6 -3
  20. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/pyproject.toml +4 -2
  21. benchlab_pytools-3.1.0/benchlab/tui/requirements.txt +0 -2
  22. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/.flake8 +0 -0
  23. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/.gitignore +0 -0
  24. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/CONTRIBUTING.md +0 -0
  25. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/LICENSE +0 -0
  26. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/__main__.py +0 -0
  27. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/bootstrap.py +0 -0
  28. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/config/__init__.py +0 -0
  29. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/config/config_manager.py +0 -0
  30. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/config/config_tool.py +0 -0
  31. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/config/diff.py +0 -0
  32. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/config/examples/complete_config.json +0 -0
  33. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/config/examples/rgb_lighting.json +0 -0
  34. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/config/examples/simple_fan.json +0 -0
  35. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/config/schema.py +0 -0
  36. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/config/test_config_manager.py +0 -0
  37. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/config/test_diff.py +0 -0
  38. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/core/__init__.py +0 -0
  39. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/core/config.py +0 -0
  40. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/core/datasource.py +0 -0
  41. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/core/datasource_manager.py +0 -0
  42. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/core/device_registry.py +0 -0
  43. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/core/discovery.py +0 -0
  44. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/core/infrastructure.py +0 -0
  45. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/core/process_manager.py +0 -0
  46. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/core/retry.py +0 -0
  47. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/core/shared_serial.py +0 -0
  48. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/core/statistics.py +0 -0
  49. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/csv_log/README.md +0 -0
  50. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/csv_log/__init__.py +0 -0
  51. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/csv_log/csv_logger_enhanced.py +0 -0
  52. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/csv_log/message_batcher.py +0 -0
  53. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/csv_log/smart_retry.py +0 -0
  54. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/graph/README.md +0 -0
  55. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/graph/__init__.py +0 -0
  56. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/graph/app.py +0 -0
  57. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/graph/requirements.txt +0 -0
  58. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/graph/runner.py +0 -0
  59. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/graph/test_graph.py +0 -0
  60. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/graph/ui.py +0 -0
  61. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/hwinfo/README.md +0 -0
  62. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/hwinfo/__init__.py +0 -0
  63. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/hwinfo/hwinfo_export.py +0 -0
  64. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/hwinfo/requirements.txt +0 -0
  65. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/hwinfo/test_hwinfo_export.py +0 -0
  66. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/launcher.py +0 -0
  67. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/link/.env.EXAMPLE +0 -0
  68. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/link/README.md +0 -0
  69. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/link/__init__.py +0 -0
  70. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/link/link_main.py +0 -0
  71. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/link/test_link_main.py +0 -0
  72. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/main.py +0 -0
  73. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/menu.py +0 -0
  74. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/menu_classic.py +0 -0
  75. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/mqtt/README.md +0 -0
  76. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/mqtt/__init__.py +0 -0
  77. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/mqtt/mqtt.config_template +0 -0
  78. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/mqtt/mqtt_publisher.py +0 -0
  79. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/mqtt/requirements.txt +0 -0
  80. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/mqtt/test_mqtt_publisher.py +0 -0
  81. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/prefs.py +0 -0
  82. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/restapi/.env.example +0 -0
  83. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/restapi/__init__.py +0 -0
  84. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/restapi/favicon.ico +0 -0
  85. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/restapi/requirements.txt +0 -0
  86. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/sources.py +0 -0
  87. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/tools.py +0 -0
  88. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/tui/README.md +0 -0
  89. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/tui/__init__.py +0 -0
  90. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/tui/config.py +0 -0
  91. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/README.md +0 -0
  92. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/NOTICE.md +0 -0
  93. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/README.md +0 -0
  94. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/Running_from_source_on_linux.md +0 -0
  95. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/__init__.py +0 -0
  96. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/assets/vu1_hello_world.png +0 -0
  97. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/community_applications.md +0 -0
  98. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/config.yaml +0 -0
  99. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/database.py +0 -0
  100. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/dial_driver.py +0 -0
  101. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/dials/Comms_Hub_Gauge.py +0 -0
  102. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/dials/Comms_Hub_Server.py +0 -0
  103. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/dials/__init__.py +0 -0
  104. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/dials/base_logger.py +0 -0
  105. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/installer/inc/icon.ico +0 -0
  106. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/installer/install.nsi +0 -0
  107. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/make_version.py +0 -0
  108. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/pylintrc +0 -0
  109. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/requirements.txt +0 -0
  110. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/serial_driver.py +0 -0
  111. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/server.py +0 -0
  112. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/server_config.py +0 -0
  113. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/server_dial_handler.py +0 -0
  114. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/vu_notifications.py +0 -0
  115. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/android-chrome-192x192.png +0 -0
  116. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/android-chrome-512x512.png +0 -0
  117. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/apple-touch-icon.png +0 -0
  118. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/css/demo.css +0 -0
  119. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/css/demo.min.css +0 -0
  120. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/css/demo.rtl.css +0 -0
  121. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/css/demo.rtl.min.css +0 -0
  122. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/css/tabler-flags.css +0 -0
  123. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/css/tabler-flags.min.css +0 -0
  124. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/css/tabler-flags.rtl.css +0 -0
  125. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/css/tabler-flags.rtl.min.css +0 -0
  126. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/css/tabler-payments.css +0 -0
  127. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/css/tabler-payments.min.css +0 -0
  128. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/css/tabler-payments.rtl.css +0 -0
  129. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/css/tabler-payments.rtl.min.css +0 -0
  130. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/css/tabler-social.css +0 -0
  131. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/css/tabler-social.min.css +0 -0
  132. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/css/tabler-social.rtl.css +0 -0
  133. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/css/tabler-social.rtl.min.css +0 -0
  134. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/css/tabler-vendors.css +0 -0
  135. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/css/tabler-vendors.min.css +0 -0
  136. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/css/tabler-vendors.rtl.css +0 -0
  137. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/css/tabler-vendors.rtl.min.css +0 -0
  138. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/css/tabler.css +0 -0
  139. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/css/tabler.min.css +0 -0
  140. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/css/tabler.rtl.css +0 -0
  141. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/css/tabler.rtl.min.css +0 -0
  142. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/css/theme.min.css +0 -0
  143. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/img/header.png +0 -0
  144. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/img/logo.png +0 -0
  145. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/js/dark-theme.js +0 -0
  146. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/js/dark-theme.min.js +0 -0
  147. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/js/demo.js +0 -0
  148. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/js/demo.min.js +0 -0
  149. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/js/jquery.min.js +0 -0
  150. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/js/tabler.esm.js +0 -0
  151. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/js/tabler.esm.min.js +0 -0
  152. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/js/tabler.js +0 -0
  153. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/js/tabler.min.js +0 -0
  154. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/js/vu1_api.js +0 -0
  155. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/js/vu1_gui_api_key_settings.js +0 -0
  156. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/js/vu1_gui_api_keys.js +0 -0
  157. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/js/vu1_gui_dial.js +0 -0
  158. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/js/vu1_gui_home.js +0 -0
  159. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/assets/js/vu1_gui_root.js +0 -0
  160. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/favicon-16x16.png +0 -0
  161. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/favicon-32x32.png +0 -0
  162. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/favicon.ico +0 -0
  163. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/favicon.png +0 -0
  164. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/index.html +0 -0
  165. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/views/api_keys.html +0 -0
  166. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/views/dial.html +0 -0
  167. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/views/footer.html +0 -0
  168. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/views/header.html +0 -0
  169. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/views/key_settings.html +0 -0
  170. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/VU-Server/www/views/start.html +0 -0
  171. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/__init__.py +0 -0
  172. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/assets/Barlow-Bold.ttf +0 -0
  173. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/assets/bl_dial_144x200.png +0 -0
  174. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/assets/bl_logo_144x200.png +0 -0
  175. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/devices.py +0 -0
  176. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/requirements.txt +0 -0
  177. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/sensors.py +0 -0
  178. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/test_devices.py +0 -0
  179. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/test_vu_server_config.py +0 -0
  180. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/test_vu_server_manager.py +0 -0
  181. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/test_vu_tui.py +0 -0
  182. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/vu_dial.config_template +0 -0
  183. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/vu_logo_gen.py +0 -0
  184. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/vu_server.config_template +0 -0
  185. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/vu_server_config.py +0 -0
  186. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/vu_server_manager.py +0 -0
  187. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/vu_tui.py +0 -0
  188. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/vu/vu_updater.py +0 -0
  189. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/wigidash/README.md +0 -0
  190. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/wigidash/__init__.py +0 -0
  191. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/wigidash/assets/Inter-Bold.ttf +0 -0
  192. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/wigidash/assets/Inter.ttf +0 -0
  193. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/wigidash/assets/benchlab.png +0 -0
  194. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/wigidash/benchlab_fleet.py +0 -0
  195. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/wigidash/benchlab_graph.py +0 -0
  196. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/wigidash/benchlab_overview.py +0 -0
  197. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/wigidash/benchlab_telemetry.py +0 -0
  198. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/wigidash/benchlab_ui.py +0 -0
  199. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/wigidash/benchlab_utils.py +0 -0
  200. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/wigidash/requirements.txt +0 -0
  201. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/wigidash/test_wigidash.py +0 -0
  202. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/wigidash/wigidash_device.py +0 -0
  203. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/wigidash/wigidash_manager.py +0 -0
  204. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/wigidash/wigidash_session.py +0 -0
  205. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/wigidash/wigidash_usb.py +0 -0
  206. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab/wigidash/wigidash_widget.py +0 -0
  207. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/benchlab.py +0 -0
  208. {benchlab_pytools-3.1.0 → benchlab_pytools-3.2.1}/pytest.ini +0 -0
@@ -4,6 +4,65 @@ All notable changes to BENCHLAB PyTools are documented here. Format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows
5
5
  [SemVer](https://semver.org/).
6
6
 
7
+ ## [3.2.1] - 2026-10-01
8
+
9
+ ### Fixed
10
+ - Collector recovery after incomplete sensor reads
11
+ (`benchlab.restapi.telemetry_api.read_device_loop`): incomplete serial
12
+ responses (`read_sensors()` returning `None`) are now treated as read
13
+ failures instead of being silently ignored, so the collector reconnects
14
+ instead of continuing to publish stale telemetry. The read-error
15
+ backoff counter now survives serial port reopen attempts and resets
16
+ only after a successfully decoded sample, restoring the intended
17
+ retry/backoff policy. A device is marked `connected` only once a
18
+ sample has actually been decoded, and stale telemetry is cleared
19
+ before the collector waits to retry. (#77)
20
+
21
+ ## [3.2.0] - 2026-09-29
22
+
23
+ ### Added
24
+ - `GET /events` WebSocket on the FastAPI server (`benchlab.restapi.telemetry_api`):
25
+ a multiplexed event stream matching the frame envelope of the C# BenchLab
26
+ service's `/events` (`hello`/`telemetry`/`device` frames), so
27
+ `ServiceWsDataSource` (`--source service_ws`) can point at either server.
28
+ `telemetry.v` keeps pytools' own TUI-style sensor-key names (e.g.
29
+ `EPS1_Power`) rather than the C# service's `ShortName` convention (e.g.
30
+ `EPS1_P`) -- there's no pycore equivalent of `ShortName` to translate
31
+ into, and `ServiceWsDataSource`'s key mapping already passes unrecognised
32
+ keys through unchanged, so no client-side changes are needed. The
33
+ existing per-device `/device/{uid}/stream` WebSocket is unchanged.
34
+ - `ConfigClient.factory_cal_unlock()` (`direct` and `named_pipe` sources):
35
+ temporarily lifts write-protection on BL2's factory calibration slot
36
+ (BL2 firmware 7+ only). Disruptive -- a successful call resets the
37
+ device immediately; reconnect before issuing further commands.
38
+
39
+ ### Changed
40
+ - Bumped the `benchlab-pycore` floor to `>=0.8.0` (from `>=0.6.0`).
41
+ 0.8.0 fixes `write_calibration()`'s wire format on BL1 firmware 6+ /
42
+ BL2 firmware 7+, where `WRITE_CALIBRATION` changed from an
43
+ offset-addressed partial write to a whole-struct replace; sending the
44
+ old format there silently corrupts calibration data.
45
+ `DirectConfigClient.write_calibration()` was already firmware-agnostic
46
+ (it doesn't pass `fw_version`, so pycore auto-probes it), so no code
47
+ change was needed here beyond the floor bump. Companion release:
48
+ BENCHLAB_Service v2.6.0, which adds the same `FACTORY_CAL_UNLOCK`
49
+ command plus BL1/BL2 firmware compatibility and a stuck-disconnect
50
+ reconnect fix.
51
+
52
+ ### Fixed
53
+ - TUI: a disconnected remote data source (`fastapi_custom`, `mqtt_custom`,
54
+ `named_pipe`, `service_http`, `service_ws`, etc.) fell back to scanning
55
+ local serial ports for the Fleet view, which could probe ports already
56
+ owned by another collector and show unrelated local devices instead of
57
+ an empty fleet. (#74)
58
+ - TUI: the terminal-too-small warning was drawn but never flushed to the
59
+ screen before `render()` returned, leaving the terminal blank instead
60
+ of showing the warning. (#75)
61
+ - Packaging: `benchlab-pytools[tui]` was missing `pydantic` and `requests`,
62
+ both required to initialize an HTTP-backed TUI (`fastapi`/`service_http`
63
+ sources); a clean `[tui]`-only install crashed with `ModuleNotFoundError`
64
+ unless the server/all extras happened to already pull them in. (#76)
65
+
7
66
  ## [3.1.0] - 2026-09-08
8
67
 
9
68
  ### Added
@@ -114,6 +173,8 @@ export, MQTT publisher, VU dials, WigiDash, and config import/export tools,
114
173
  sharing a common data-source layer (direct serial, FastAPI, MQTT, named
115
174
  pipe, service HTTP, service WebSocket).
116
175
 
176
+ [3.2.1]: https://github.com/BenchLab-io/benchlab-pytools/compare/v3.2.0...v3.2.1
177
+ [3.2.0]: https://github.com/BenchLab-io/benchlab-pytools/compare/v3.1.0...v3.2.0
117
178
  [3.1.0]: https://github.com/BenchLab-io/benchlab-pytools/compare/v3.0.4...v3.1.0
118
179
  [3.0.4]: https://github.com/BenchLab-io/benchlab-pytools/compare/v3.0.3...v3.0.4
119
180
  [3.0.3]: https://github.com/BenchLab-io/benchlab-pytools/compare/v3.0.2...v3.0.3
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: benchlab-pytools
3
- Version: 3.1.0
3
+ Version: 3.2.1
4
4
  Summary: Python control suite for BENCHLAB telemetry devices
5
5
  Project-URL: Homepage, https://benchlab.io/
6
6
  Project-URL: Repository, https://github.com/BenchLab-io/benchlab-pytools
@@ -22,7 +22,7 @@ Classifier: Programming Language :: Python :: 3.13
22
22
  Classifier: Topic :: System :: Hardware
23
23
  Classifier: Topic :: System :: Monitoring
24
24
  Requires-Python: >=3.10
25
- Requires-Dist: benchlab-pycore>=0.6.0
25
+ Requires-Dist: benchlab-pycore>=0.8.0
26
26
  Requires-Dist: packaging<26.0,>=23.0
27
27
  Requires-Dist: python-dotenv<2.0,>=1.2.2
28
28
  Requires-Dist: pywin32>=306; platform_system == 'Windows'
@@ -30,7 +30,7 @@ Requires-Dist: textual<9.0,>=0.60
30
30
  Requires-Dist: windows-curses>=2.4.1; platform_system == 'Windows'
31
31
  Provides-Extra: all
32
32
  Requires-Dist: amqtt<0.13.0,>=0.12.0; extra == 'all'
33
- Requires-Dist: benchlab-pycore>=0.6.0; extra == 'all'
33
+ Requires-Dist: benchlab-pycore>=0.8.0; extra == 'all'
34
34
  Requires-Dist: blessed>=1.20.0; extra == 'all'
35
35
  Requires-Dist: dearpygui>=2.1.1; extra == 'all'
36
36
  Requires-Dist: fastapi==0.111.1; extra == 'all'
@@ -48,12 +48,13 @@ Requires-Dist: pyusb>=1.3.1; extra == 'all'
48
48
  Requires-Dist: pyyaml<7.0,>=6.0; extra == 'all'
49
49
  Requires-Dist: pyyaml>=6.0; extra == 'all'
50
50
  Requires-Dist: requests>=2.31.0; extra == 'all'
51
+ Requires-Dist: requests>=2.33.1; extra == 'all'
51
52
  Requires-Dist: ruamel-yaml; extra == 'all'
52
53
  Requires-Dist: tornado; extra == 'all'
53
54
  Requires-Dist: uvicorn[standard]==0.24.0; extra == 'all'
54
55
  Requires-Dist: websockets<16.0,>=13.0; extra == 'all'
55
56
  Provides-Extra: csv-log
56
- Requires-Dist: benchlab-pycore>=0.6.0; extra == 'csv-log'
57
+ Requires-Dist: benchlab-pycore>=0.8.0; extra == 'csv-log'
57
58
  Requires-Dist: pyserial>=3.5; extra == 'csv-log'
58
59
  Provides-Extra: graph
59
60
  Requires-Dist: dearpygui>=2.1.1; extra == 'graph'
@@ -73,7 +74,9 @@ Requires-Dist: uvicorn[standard]==0.24.0; extra == 'restapi'
73
74
  Provides-Extra: service-ws
74
75
  Requires-Dist: websockets<16.0,>=13.0; extra == 'service-ws'
75
76
  Provides-Extra: tui
77
+ Requires-Dist: pydantic>=2.0.0; extra == 'tui'
76
78
  Requires-Dist: pyserial>=3.5; extra == 'tui'
79
+ Requires-Dist: requests>=2.33.1; extra == 'tui'
77
80
  Provides-Extra: vu
78
81
  Requires-Dist: blessed>=1.20.0; extra == 'vu'
79
82
  Requires-Dist: numpy; extra == 'vu'
@@ -196,6 +199,10 @@ Most tools accept `--source` to choose where telemetry comes from:
196
199
  `/events` WebSocket: telemetry is pushed at the service's own cadence instead of
197
200
  polled, and device connect/disconnect is reflected without a manual rescan.
198
201
  Install its dependency with `pip install "benchlab-pytools[service_ws]"`.
202
+ The pytools FastAPI server (`fastapi`/`fastapi_custom`) also exposes an
203
+ `/events` endpoint with the same frame envelope, so `--source service_ws
204
+ --service-ws-url ws://<fastapi-host>:<port>/events` works against it too —
205
+ see `benchlab/restapi/readme.md`.
199
206
 
200
207
  Common connection flags:
201
208
 
@@ -102,6 +102,10 @@ Most tools accept `--source` to choose where telemetry comes from:
102
102
  `/events` WebSocket: telemetry is pushed at the service's own cadence instead of
103
103
  polled, and device connect/disconnect is reflected without a manual rescan.
104
104
  Install its dependency with `pip install "benchlab-pytools[service_ws]"`.
105
+ The pytools FastAPI server (`fastapi`/`fastapi_custom`) also exposes an
106
+ `/events` endpoint with the same frame envelope, so `--source service_ws
107
+ --service-ws-url ws://<fastapi-host>:<port>/events` works against it too —
108
+ see `benchlab/restapi/readme.md`.
105
109
 
106
110
  Common connection flags:
107
111
 
@@ -2,4 +2,4 @@
2
2
  BENCHLAB Telemetry Package
3
3
  """
4
4
 
5
- __version__ = "3.1.0"
5
+ __version__ = "3.2.1"
@@ -315,6 +315,21 @@ manager.export_config('COM4', 'output.json')
315
315
  manager.import_config('input.json', dry_run=False)
316
316
  ```
317
317
 
318
+ For lower-level access (used internally by `ConfigManager`), `create_config_client()`
319
+ returns a `ConfigClient` with `read_calibration()`/`write_calibration()`,
320
+ `save_config()`/`load_config()`/`reset_config()`, and (BL2 firmware 7+ only)
321
+ `factory_cal_unlock()`, which temporarily lifts write-protection on the factory
322
+ calibration slot. There is no CLI/JSON-config surface for `factory_cal_unlock()` —
323
+ it's a niche factory-calibration tool, not a user-facing config option, and a
324
+ successful call resets the device immediately:
325
+
326
+ ```python
327
+ from benchlab.config.config_client import create_config_client
328
+
329
+ client = create_config_client('direct', 'COM4')
330
+ client.factory_cal_unlock() # BL2 fw7+ only; device resets on success
331
+ ```
332
+
318
333
  ## See Also
319
334
 
320
335
  - [BENCHLAB PyTools Documentation](../../README.md) - Main PyTools documentation
@@ -149,6 +149,17 @@ class ConfigClient(ABC):
149
149
  """Reset configuration to factory defaults."""
150
150
  pass
151
151
 
152
+ @abstractmethod
153
+ def factory_cal_unlock(self) -> bool:
154
+ """Temporarily lift write-protection on BL2's factory calibration
155
+ slot (BL2 fw07+ only; rejected on ORIGINAL or older BL2 firmware).
156
+
157
+ Disruptive: a successful call resets the device immediately -- the
158
+ connection drops and must be reestablished before sending further
159
+ commands.
160
+ """
161
+ pass
162
+
152
163
  @abstractmethod
153
164
  def close(self):
154
165
  """Close connection and cleanup resources."""
@@ -438,6 +449,30 @@ class DirectConfigClient(ConfigClient):
438
449
  from benchlab_pycore.core import send_action
439
450
  return send_action(self.ser, action=self._CONFIG_ACTION_RESET)
440
451
 
452
+ def factory_cal_unlock(self) -> bool:
453
+ """Temporarily lift write-protection on BL2's factory calibration
454
+ slot. BL2 fw07+ only -- rejected on ORIGINAL or older BL2 firmware.
455
+
456
+ Disruptive: a successful call resets the device immediately -- the
457
+ USB CDC port drops and re-enumerates, so callers must reconnect
458
+ before sending any further command.
459
+ """
460
+ from benchlab_pycore.core import (
461
+ factory_cal_unlock, BENCHLAB_BL2_PRODUCT_ID)
462
+
463
+ # pycore's factory_cal_unlock() only reports whether the passphrase
464
+ # bytes were written to the serial port, not whether the firmware
465
+ # accepted them -- ORIGINAL and older BL2 firmware silently ignore
466
+ # the unknown opcode with no ACK either way. Gate on product_id
467
+ # ourselves so callers get an honest False instead of a misleading
468
+ # True on hardware where the unlock can never actually take effect.
469
+ if self.product_id != BENCHLAB_BL2_PRODUCT_ID:
470
+ logger.warning(
471
+ "factory_cal_unlock: not a BL2 device, refusing to send "
472
+ "(unknown opcode on ORIGINAL firmware)")
473
+ return False
474
+ return factory_cal_unlock(self.ser)
475
+
441
476
  def close(self):
442
477
  """Close serial connection."""
443
478
  if self.ser and self.ser.is_open:
@@ -611,6 +646,17 @@ class NamedPipeConfigClient(ConfigClient):
611
646
  result = self._send_command("ResetConfig")
612
647
  return result and result.get('success', False)
613
648
 
649
+ def factory_cal_unlock(self) -> bool:
650
+ """Temporarily lift write-protection on BL2's factory calibration
651
+ slot. BL2 fw07+ only -- rejected on ORIGINAL or older BL2 firmware.
652
+
653
+ Disruptive: a successful call resets the device immediately -- the
654
+ pipe and COM port disconnect, so callers must reconnect before
655
+ sending any further command.
656
+ """
657
+ result = self._send_command("SendFactoryCalUnlock", payload="benchlab")
658
+ return result and result.get('success', False)
659
+
614
660
  def close(self):
615
661
  """Close pipe connection."""
616
662
  self._close_pipe()
@@ -164,3 +164,89 @@ def test_save_config_propagates_a_failed_send(monkeypatch):
164
164
  "benchlab_pycore.core.send_action", lambda ser, action: False)
165
165
 
166
166
  assert client.save_config() is False
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # factory_cal_unlock -- benchlab-pycore 0.8.0 / BENCHLAB_Service 2.6.0
171
+ #
172
+ # New BL2 fw07+ only command that temporarily lifts write-protection on the
173
+ # factory calibration slot. Disruptive (resets the device on success), but
174
+ # from this client's perspective it's a thin pass-through: DirectConfigClient
175
+ # calls pycore's factory_cal_unlock(ser); NamedPipeConfigClient sends the
176
+ # SendFactoryCalUnlock pipe command with the fixed passphrase payload.
177
+ # ---------------------------------------------------------------------------
178
+
179
+ def test_direct_factory_cal_unlock_calls_pycore(monkeypatch):
180
+ client = _make_client(BENCHLAB_BL2_PRODUCT_ID)
181
+ captured = {}
182
+
183
+ def fake_factory_cal_unlock(ser):
184
+ captured["called"] = True
185
+ return True
186
+ monkeypatch.setattr(
187
+ "benchlab_pycore.core.factory_cal_unlock", fake_factory_cal_unlock)
188
+
189
+ assert client.factory_cal_unlock() is True
190
+ assert captured["called"] is True
191
+
192
+
193
+ def test_direct_factory_cal_unlock_refuses_non_bl2_device(monkeypatch):
194
+ """pycore's factory_cal_unlock() only reports whether bytes were
195
+ written, not whether the firmware accepted them -- confirmed against
196
+ real BL1 hardware, which returns True even though the opcode is
197
+ unknown on ORIGINAL firmware and nothing happens. Gate on product_id
198
+ ourselves so callers get an honest False."""
199
+ client = _make_client(BENCHLAB_ORIGINAL_PRODUCT_ID)
200
+ captured = {}
201
+
202
+ def fake_factory_cal_unlock(ser):
203
+ captured["called"] = True
204
+ return True
205
+ monkeypatch.setattr(
206
+ "benchlab_pycore.core.factory_cal_unlock", fake_factory_cal_unlock)
207
+
208
+ assert client.factory_cal_unlock() is False
209
+ assert "called" not in captured
210
+
211
+
212
+ def test_direct_factory_cal_unlock_propagates_a_failed_call(monkeypatch):
213
+ client = _make_client(BENCHLAB_BL2_PRODUCT_ID)
214
+ monkeypatch.setattr(
215
+ "benchlab_pycore.core.factory_cal_unlock", lambda ser: False)
216
+
217
+ assert client.factory_cal_unlock() is False
218
+
219
+
220
+ def test_named_pipe_factory_cal_unlock_sends_command_and_passphrase(
221
+ monkeypatch):
222
+ from benchlab.config.config_client import NamedPipeConfigClient
223
+
224
+ client = NamedPipeConfigClient.__new__(NamedPipeConfigClient)
225
+ client.pipe_name = "BenchlabSensorPipe_11_TEST"
226
+ client.handle = None
227
+
228
+ captured = {}
229
+
230
+ def fake_send_command(command, payload=None):
231
+ captured["command"] = command
232
+ captured["payload"] = payload
233
+ return {"success": True}
234
+ monkeypatch.setattr(client, "_send_command", fake_send_command)
235
+
236
+ assert client.factory_cal_unlock() is True
237
+ assert captured["command"] == "SendFactoryCalUnlock"
238
+ assert captured["payload"] == "benchlab"
239
+
240
+
241
+ def test_named_pipe_factory_cal_unlock_propagates_a_failed_call(monkeypatch):
242
+ from benchlab.config.config_client import NamedPipeConfigClient
243
+
244
+ client = NamedPipeConfigClient.__new__(NamedPipeConfigClient)
245
+ client.pipe_name = "BenchlabSensorPipe_11_TEST"
246
+ client.handle = None
247
+
248
+ monkeypatch.setattr(
249
+ client, "_send_command",
250
+ lambda command, payload=None: {"success": False})
251
+
252
+ assert client.factory_cal_unlock() is False
@@ -27,7 +27,7 @@ A **DataSource abstraction layer** (`datasource.py`) with multiple implementatio
27
27
  | `MQTTDataSource` | `mqtt` / `mqtt_custom` | Subscribes to `<topic_prefix>/+/telemetry` and `<topic_prefix>/+/info` on an MQTT broker via `paho-mqtt`. Resolves `MQTT_PROTOCOL` (v3.1 / v3.1.1 / v5) via the module-level `resolve_mqtt_protocol()` helper. |
28
28
  | `NamedPipeDataSource` | `named_pipe` | Windows-only. Talks to the C# BENCHLAB Windows service over named pipes (`BenchlabDiscovery` for device listing, per-device `BenchlabSensorPipe_XX_YYY` pipes for telemetry). Normalizes the C# service's `ShortName`-keyed sensor payloads into the same flat key names Python tools expect, via the module-level `_normalise_cs_telemetry()` helper. |
29
29
  | `ServiceHttpDataSource` | `service_http` | REST client for the C# BENCHLAB service's HTTP API (default `http://localhost:8585`). Thin client only — does not start/manage the service. Same telemetry normalization as `NamedPipeDataSource`. |
30
- | `ServiceWsDataSource` | `service_ws` | Client for the C# BENCHLAB service's `/events` WebSocket stream (default `ws://localhost:8585/events`). Runs an asyncio event loop on a background daemon thread; consumes pushed `hello` / `telemetry` / `device` frames (no poll loop). Same telemetry normalization as `ServiceHttpDataSource` (shared `CS_SHORT_NAME_MAP` / `_normalise_cs_v()`). Requires the `websockets` package (`benchlab-pytools[service_ws]`). |
30
+ | `ServiceWsDataSource` | `service_ws` | Client for the C# BENCHLAB service's `/events` WebSocket stream (default `ws://localhost:8585/events`). Runs an asyncio event loop on a background daemon thread; consumes pushed `hello` / `telemetry` / `device` frames (no poll loop). Same telemetry normalization as `ServiceHttpDataSource` (shared `CS_SHORT_NAME_MAP` / `_normalise_cs_v()`). Requires the `websockets` package (`benchlab-pytools[service_ws]`). The pytools FastAPI server (`benchlab.restapi.telemetry_api`) exposes the same `/events` frame envelope, so this class also works unmodified against it — its `telemetry.v` keys are unmapped by `_normalise_cs_v()` since they're already in the target key convention, not the C# `ShortName` one. |
31
31
 
32
32
  Each implementation validates its constructor kwargs through a Pydantic model in `config.py` (`SerialConfig`, `FastAPIConfig`, `MQTTConfig`, `NamedPipeConfig`, `ServiceHttpConfig`, `ServiceWsConfig`).
33
33
 
@@ -1,3 +1,3 @@
1
1
  # benchlab/csv_log/requirements.txt
2
2
  pyserial>=3.5
3
- benchlab-pycore>=0.6.0
3
+ benchlab-pycore>=0.8.0
@@ -4,7 +4,7 @@ python-dotenv>=1.2.2,<2.0
4
4
  textual>=0.60,<9.0
5
5
 
6
6
  # BENCHLAB Serial Handler
7
- benchlab-pycore>=0.6.0
7
+ benchlab-pycore>=0.8.0
8
8
 
9
9
  # FastAPI Data Source
10
10
  fastapi==0.111.1
@@ -61,6 +61,7 @@ The server will start on `http://0.0.0.0:8000` by default (configurable via `API
61
61
 
62
62
  ### Real-time Streaming
63
63
  - `WebSocket /device/{uid}/stream` - Real-time telemetry updates, pushed as each device's reader thread reads new sensor data
64
+ - `WebSocket /events` - Multiplexed event stream for all devices: a `hello` frame on connect (service version, poll interval, device list), then `telemetry` and `device` (connected/disconnected) frames as they occur. Matches the frame envelope of the C# BENCHLAB service's `/events` (see `ServiceWsDataSource`, `--source service_ws`), so the same client works against either server — except `telemetry.v` keeps this server's own long-form sensor-key names (e.g. `EPS1_Power`) rather than the C# service's `ShortName` convention (e.g. `EPS1_P`).
64
65
 
65
66
  ### Monitoring
66
67
  - `GET /health` - Basic health check (status, platform, timestamp, connected_devices, total_clients)
@@ -131,6 +132,16 @@ ws.onmessage = (event) => {
131
132
  };
132
133
  ```
133
134
 
135
+ ### Multiplexed Event Stream (WebSocket)
136
+ ```javascript
137
+ const ws = new WebSocket('ws://localhost:8000/events');
138
+ ws.onmessage = (event) => {
139
+ const frame = JSON.parse(event.data);
140
+ // frame.type is "hello", "telemetry", or "device"
141
+ console.log(frame);
142
+ };
143
+ ```
144
+
134
145
  ## Production Deployment
135
146
 
136
147
  ### Docker Support
@@ -14,6 +14,7 @@ from fastapi.middleware.cors import CORSMiddleware
14
14
  from fastapi.responses import FileResponse, JSONResponse
15
15
  from pathlib import Path
16
16
 
17
+ import benchlab
17
18
  from benchlab_pycore.core import (
18
19
  read_sensors, read_device, translate_sensor_struct)
19
20
  from benchlab.core import (
@@ -86,6 +87,7 @@ api_port = Config.API_PORT
86
87
  # { uid: { "port": str, "latest": dict, "history": deque, "connected": bool } }
87
88
  devices_data = {}
88
89
  clients = {} # { uid: set([WebSocket, ...]) }
90
+ event_clients = set() # WebSockets subscribed to the /events stream
89
91
  main_loop = None # Will store main asyncio loop
90
92
  shutdown_event = threading.Event() # Graceful shutdown flag
91
93
  device_threads = {} # { uid: threading.Thread } - device reader threads
@@ -142,9 +144,12 @@ def device_scanner_loop():
142
144
  logger.info(
143
145
  "Device %s appears disconnected "
144
146
  "(port %s not found)", uid, port)
147
+ was_connected = data.get("connected", False)
145
148
  with data_lock:
146
149
  if uid in devices_data:
147
150
  devices_data[uid]["connected"] = False
151
+ if was_connected:
152
+ schedule_device_event(uid, "disconnected")
148
153
 
149
154
  logger.debug("Scanner: scan complete")
150
155
 
@@ -277,10 +282,77 @@ async def send_updates(uid, data):
277
282
  clients[uid].discard(ws)
278
283
 
279
284
 
285
+ async def _broadcast_event(frame):
286
+ """Push a hello/telemetry/device frame to every /events subscriber."""
287
+ with data_lock:
288
+ ws_list = list(event_clients)
289
+ dead_clients = set()
290
+ for ws in ws_list:
291
+ try:
292
+ await ws.send_json(frame)
293
+ except Exception:
294
+ dead_clients.add(ws)
295
+ if dead_clients:
296
+ with data_lock:
297
+ event_clients.difference_update(dead_clients)
298
+
299
+
300
+ async def send_event_telemetry(uid, data):
301
+ """Push a /events "telemetry" frame for this UID's latest reading.
302
+
303
+ ``v`` keeps pytools' own TUI-style sensor keys (e.g. "EPS1_Power")
304
+ rather than the C# service's ShortName convention (e.g. "EPS1_P") --
305
+ there's no pycore equivalent of ShortName to translate into, and
306
+ ServiceWsDataSource's ShortName->TUI-key mapping already passes
307
+ unrecognised keys through unchanged, so this is a no-op there rather
308
+ than a translation, but no client-side change is required.
309
+ """
310
+ await _broadcast_event({
311
+ "type": "telemetry",
312
+ "uid": uid,
313
+ "ts": data.get("timestamp"),
314
+ "v": {k: v for k, v in data.items() if k != "timestamp"},
315
+ })
316
+
317
+
280
318
  def schedule_update(uid, data):
281
319
  """Thread-safe schedule to send telemetry to WebSocket clients."""
282
320
  if main_loop is not None and not main_loop.is_closed():
283
321
  asyncio.run_coroutine_threadsafe(send_updates(uid, data), main_loop)
322
+ asyncio.run_coroutine_threadsafe(
323
+ send_event_telemetry(uid, data), main_loop)
324
+
325
+
326
+ def _device_to_wire_shape(uid, info=None):
327
+ """Shape a device's info the way /events' hello/device frames expect
328
+ (mirrors the C# service's device shape), independent of GET /devices'
329
+ own response shape."""
330
+ if info is None:
331
+ with data_lock:
332
+ info = devices_data.get(uid, {})
333
+ device_info = info.get("info", {}) or {}
334
+ return {
335
+ "uid": uid,
336
+ "name": device_info.get("deviceName", ""),
337
+ "productId": device_info.get("ProductId", 0),
338
+ "port": info.get("port", "unknown"),
339
+ "status": "CONNECTED" if info.get("connected", False) else
340
+ "DISCONNECTED",
341
+ "sensorCount": len(info.get("latest", {}) or {}),
342
+ "firmwareVersion": device_info.get("FwVersion", 0),
343
+ }
344
+
345
+
346
+ def schedule_device_event(uid, event):
347
+ """Thread-safe schedule to broadcast a /events "device" frame."""
348
+ if main_loop is not None and not main_loop.is_closed():
349
+ frame = {
350
+ "type": "device",
351
+ "uid": uid,
352
+ "event": event,
353
+ "device": _device_to_wire_shape(uid),
354
+ }
355
+ asyncio.run_coroutine_threadsafe(_broadcast_event(frame), main_loop)
284
356
 
285
357
 
286
358
  RECONNECT_DELAY = 2.0 # seconds
@@ -369,29 +441,25 @@ def read_device_loop(port, uid):
369
441
  reconnection logic."""
370
442
  ser = None
371
443
  consecutive_errors = 0
372
- max_consecutive_errors = 10 # After this many errors, attempt reconnect
444
+ max_consecutive_errors = 10 # Back off after repeated read failures
373
445
  product_id = None # Will be set once we read device info
374
- is_connected = False # Track connection status
446
+ is_connected = False # Only valid telemetry establishes a connection
447
+ with data_lock:
448
+ was_connected = devices_data.get(uid, {}).get("connected", False)
449
+ if uid in devices_data:
450
+ devices_data[uid]["connected"] = False
451
+ devices_data[uid]["latest"] = create_empty_telemetry()
452
+ if was_connected:
453
+ schedule_device_event(uid, "disconnected")
375
454
 
376
455
  while not shutdown_event.is_set():
377
456
  # --- (Re)connect ---
378
457
  if ser is None:
379
- # Mark as disconnected
380
- if is_connected:
381
- is_connected = False
382
- with data_lock:
383
- if uid in devices_data:
384
- devices_data[uid]["connected"] = False
385
- devices_data[uid]["latest"] = create_empty_telemetry()
386
- logger.info("[%s] Device disconnected", uid)
387
-
388
458
  try:
389
459
  new_ser = open_serial_connection(port)
390
460
  if new_ser is None:
391
461
  raise OSError("open_serial_connection returned None")
392
462
  ser = new_ser
393
- # Reset error counter on successful connect
394
- consecutive_errors = 0
395
463
 
396
464
  # Read device info to get product_id for sensor reading
397
465
  try:
@@ -408,17 +476,12 @@ def read_device_loop(port, uid):
408
476
  except Exception:
409
477
  pass
410
478
 
411
- is_connected = True
412
- with data_lock:
413
- if uid in devices_data:
414
- devices_data[uid]["connected"] = True
415
- logger.info("Connected to device %s on %s", uid, port)
416
479
  except Exception as exc:
417
480
  ser = None # Ensure ser is None on error
418
481
  consecutive_errors += 1
419
482
  delay = min(
420
483
  RECONNECT_DELAY * consecutive_errors,
421
- 30) # Exponential backoff, capped
484
+ 30) # Linear backoff, capped
422
485
  logger.warning(
423
486
  "[%s] Failed to open serial port %s: %s (retry in %.1fs)",
424
487
  uid,
@@ -435,6 +498,8 @@ def read_device_loop(port, uid):
435
498
  shutdown_event.wait(1)
436
499
  continue
437
500
  sensors = read_sensors(ser, product_id=product_id)
501
+ if sensors is None:
502
+ raise OSError("No complete sensor response received")
438
503
  if sensors:
439
504
  translated = translate_sensor_struct(sensors)
440
505
  translated["timestamp"] = datetime.now().strftime(
@@ -444,12 +509,15 @@ def read_device_loop(port, uid):
444
509
 
445
510
  with data_lock:
446
511
  if uid in devices_data:
512
+ devices_data[uid]["connected"] = True
447
513
  devices_data[uid]["latest"] = translated
448
514
  devices_data[uid]["history"].append(translated)
449
515
 
516
+ if not is_connected:
517
+ is_connected = True
518
+ schedule_device_event(uid, "connected")
519
+ logger.info("Connected to device %s on %s", uid, port)
450
520
  schedule_update(uid, translated)
451
- else:
452
- logger.debug("[%s] No sensor data read", uid)
453
521
  except Exception as e:
454
522
  consecutive_errors += 1
455
523
  # Specific debug logging for unsupported commands
@@ -473,8 +541,18 @@ def read_device_loop(port, uid):
473
541
  except Exception:
474
542
  pass
475
543
  ser = None
544
+ was_connected = is_connected
545
+ if is_connected:
546
+ logger.info("[%s] Device disconnected", uid)
547
+ is_connected = False
548
+ with data_lock:
549
+ if uid in devices_data:
550
+ devices_data[uid]["connected"] = False
551
+ devices_data[uid]["latest"] = create_empty_telemetry()
552
+ if was_connected:
553
+ schedule_device_event(uid, "disconnected")
476
554
 
477
- # Exponential backoff with cap
555
+ # Linear backoff with cap
478
556
  if consecutive_errors >= max_consecutive_errors:
479
557
  delay = min(RECONNECT_DELAY * consecutive_errors, 30)
480
558
  logger.warning(
@@ -490,6 +568,7 @@ def read_device_loop(port, uid):
490
568
  if uid in devices_data:
491
569
  devices_data[uid]["connected"] = False
492
570
  devices_data[uid]["latest"] = create_empty_telemetry()
571
+ schedule_device_event(uid, "disconnected")
493
572
 
494
573
  if ser:
495
574
  try:
@@ -627,6 +706,50 @@ async def stream_device(uid: str, ws: WebSocket):
627
706
  logger.info("[%s] Client disconnected (%d total)", uid, client_count)
628
707
 
629
708
 
709
+ def build_hello_frame():
710
+ """Build the /events "hello" frame sent on connect: service version,
711
+ poll interval, and the current device list, matching the C# service's
712
+ /events wire shape."""
713
+ with data_lock:
714
+ uids = list(devices_data.keys())
715
+ return {
716
+ "type": "hello",
717
+ "serviceVersion": benchlab.__version__,
718
+ "pollIntervalMs": int(Config.POLL_INTERVAL * 1000),
719
+ "devices": [_device_to_wire_shape(uid) for uid in uids],
720
+ }
721
+
722
+
723
+ @app.websocket("/events")
724
+ async def events_stream(ws: WebSocket):
725
+ """Multiplexed event stream: hello on connect, then telemetry/device
726
+ frames for every device, matching the C# BenchLab service's /events
727
+ protocol (see ServiceWsDataSource in benchlab.core.datasource, the
728
+ consumer this endpoint is meant to be compatible with)."""
729
+ await ws.accept()
730
+ with data_lock:
731
+ event_clients.add(ws)
732
+ await ws.send_json(build_hello_frame())
733
+ logger.info("[/events] Client connected (%d total)", len(event_clients))
734
+ try:
735
+ while True:
736
+ msg = await ws.receive_json()
737
+ if isinstance(msg, dict) and msg.get("type") == "ping":
738
+ await ws.send_json({
739
+ "type": "pong",
740
+ "ts": datetime.now().strftime("%Y-%m-%dT%H:%M:%S"),
741
+ })
742
+ # "subscribe" and any other client message: accepted, no-op --
743
+ # this endpoint always broadcasts every device to every client.
744
+ except WebSocketDisconnect:
745
+ pass
746
+ finally:
747
+ with data_lock:
748
+ event_clients.discard(ws)
749
+ logger.info(
750
+ "[/events] Client disconnected (%d total)", len(event_clients))
751
+
752
+
630
753
  @app.get("/favicon.ico")
631
754
  def favicon():
632
755
  return FileResponse(Path(__file__).parent / "favicon.ico")
@@ -766,9 +889,12 @@ def scan_for_devices():
766
889
  disconnected.append({"uid": uid, "port": port})
767
890
  logger.info(
768
891
  "Device %s appears to be disconnected from %s", uid, port)
892
+ was_connected = data.get("connected", False)
769
893
  with data_lock:
770
894
  if uid in devices_data:
771
895
  devices_data[uid]["connected"] = False
896
+ if was_connected:
897
+ schedule_device_event(uid, "disconnected")
772
898
 
773
899
  result = {
774
900
  "scan_time": datetime.now().isoformat(),