remoteRF-server 1.7.14__tar.gz → 1.7.16__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. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/PKG-INFO +2 -2
  2. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/pyproject.toml +2 -2
  3. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/remote_admin_shell.py +1 -1
  4. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/commands.py +1 -0
  5. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/device_manager.py +30 -1
  6. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/global_onboarding.py +60 -19
  7. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/grpc_server.py +24 -1
  8. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/reservation.py +23 -4
  9. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server.egg-info/PKG-INFO +2 -2
  10. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server.egg-info/requires.txt +1 -1
  11. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_client_sessions.py +35 -7
  12. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_global_onboarding.py +93 -6
  13. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_json_commands.py +83 -3
  14. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/LICENSE +0 -0
  15. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/README.md +0 -0
  16. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/setup.cfg +0 -0
  17. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/__init__.py +0 -0
  18. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/common/__init__.py +0 -0
  19. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/common/grpc/__init__.py +0 -0
  20. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/common/grpc/deployment_capabilities.proto +0 -0
  21. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/common/grpc/deployment_capabilities_pb2.py +0 -0
  22. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/common/grpc/deployment_capabilities_pb2_grpc.py +0 -0
  23. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/common/grpc/federation.proto +0 -0
  24. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/common/grpc/federation_pb2.py +0 -0
  25. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/common/grpc/federation_pb2_grpc.py +0 -0
  26. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/common/grpc/grpc_host_pb2.py +0 -0
  27. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/common/grpc/grpc_host_pb2_grpc.py +0 -0
  28. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/common/grpc/grpc_pb2.py +0 -0
  29. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/common/grpc/grpc_pb2_grpc.py +0 -0
  30. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/common/grpc/v2_codec.py +0 -0
  31. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/common/idl/__init__.py +0 -0
  32. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/common/idl/device_schema.py +0 -0
  33. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/common/idl/pluto_mimo_schema.py +0 -0
  34. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/common/idl/pluto_schema.py +0 -0
  35. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/common/idl/schema.py +0 -0
  36. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/common/idl/v2.py +0 -0
  37. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/common/utils/__init__.py +0 -0
  38. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/common/utils/ansi_codes.py +0 -0
  39. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/common/utils/api_token.py +0 -0
  40. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/common/utils/db_connection.py +0 -0
  41. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/common/utils/db_location.py +0 -0
  42. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/common/utils/list_string.py +0 -0
  43. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/common/utils/panel.py +0 -0
  44. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/common/utils/process_arg.py +0 -0
  45. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/drivers/__init__.py +0 -0
  46. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/drivers/adalm_pluto/__init__.py +0 -0
  47. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/drivers/adalm_pluto/pluto_diag.py +0 -0
  48. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/drivers/adalm_pluto/pluto_mimo_schema.py +0 -0
  49. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/drivers/adalm_pluto/pluto_remote_server.py +0 -0
  50. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/drivers/adalm_pluto/pluto_schema.py +0 -0
  51. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/drivers/adalm_pluto/poe.py +0 -0
  52. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/drivers/bluetooth_diag.py +0 -0
  53. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/drivers/gps_diag.py +0 -0
  54. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/drivers/hackrf/__init__.py +0 -0
  55. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/drivers/hackrf/hackrf_schema.py +0 -0
  56. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/drivers/rtl_sdr/__init__.py +0 -0
  57. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/drivers/rtl_sdr/rtl_sdr_diag.py +0 -0
  58. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/drivers/rtl_sdr/rtl_sdr_schema.py +0 -0
  59. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/drivers/ti_mmwave/__init__.py +0 -0
  60. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/drivers/ti_mmwave/ti_mmwave_schema.py +0 -0
  61. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/drivers/usrp/__init__.py +0 -0
  62. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/drivers/usrp/policy.py +0 -0
  63. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/drivers/usrp/profiles.py +0 -0
  64. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/drivers/usrp/uhd_manifest.py +0 -0
  65. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/drivers/usrp/usrp_diag.py +0 -0
  66. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/drivers/usrp/usrp_schema.py +0 -0
  67. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/drivers/usrp_phaser/__init__.py +0 -0
  68. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/drivers/usrp_phaser/usrp_phaser_schema.py +0 -0
  69. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/drivers/wifi/__init__.py +0 -0
  70. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/drivers/wifi/wifi_schema.py +0 -0
  71. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/host/__init__.py +0 -0
  72. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/host/host_auth_token.py +0 -0
  73. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/host/host_directory_store.py +0 -0
  74. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/host/host_tunnel_server.py +0 -0
  75. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/__init__.py +0 -0
  76. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/acc_perms.py +0 -0
  77. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/cert_provider.py +0 -0
  78. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/console.py +0 -0
  79. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/control.py +0 -0
  80. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/deployment/__init__.py +0 -0
  81. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/deployment/capabilities.py +0 -0
  82. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/diag.py +0 -0
  83. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/direct_path.py +0 -0
  84. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/dynamic_v2.py +0 -0
  85. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/dynamic_v2_bridge.py +0 -0
  86. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/federation/__init__.py +0 -0
  87. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/federation/admin.py +0 -0
  88. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/federation/cli.py +0 -0
  89. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/federation/cluster.py +0 -0
  90. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/federation/contracts.py +0 -0
  91. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/federation/destination.py +0 -0
  92. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/federation/devices.py +0 -0
  93. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/federation/enrollment.py +0 -0
  94. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/federation/global_home.py +0 -0
  95. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/federation/home.py +0 -0
  96. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/federation/home_devices.py +0 -0
  97. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/federation/identity.py +0 -0
  98. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/federation/legacy_state.py +0 -0
  99. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/federation/links.py +0 -0
  100. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/federation/migrations.py +0 -0
  101. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/federation/peers.py +0 -0
  102. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/federation/permissions.py +0 -0
  103. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/federation/principals.py +0 -0
  104. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/federation/runtime.py +0 -0
  105. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/federation/trust.py +0 -0
  106. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/host_direct.py +0 -0
  107. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/host_dynamic_v2.py +0 -0
  108. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/json_output.py +0 -0
  109. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/remote_admin.py +0 -0
  110. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/remote_admin_ssh.py +0 -0
  111. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/rpc_manager.py +0 -0
  112. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/schema_registry.py +0 -0
  113. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/startup_check.py +0 -0
  114. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/user_group_cli.py +0 -0
  115. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/user_group_handler.py +0 -0
  116. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/server/v2_errors.py +0 -0
  117. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/serverrf_cli.py +0 -0
  118. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/tools/__init__.py +0 -0
  119. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/tools/gen_certs.py +0 -0
  120. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server/tools/introspect_uhd.py +0 -0
  121. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server.egg-info/SOURCES.txt +0 -0
  122. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server.egg-info/dependency_links.txt +0 -0
  123. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server.egg-info/entry_points.txt +0 -0
  124. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/remoteRF_server.egg-info/top_level.txt +0 -0
  125. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/rssi/__init__.py +0 -0
  126. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/rssi/cli.py +0 -0
  127. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/rssi/dongle.py +0 -0
  128. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/rssi/safety.py +0 -0
  129. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/src/rssi/scanner.py +0 -0
  130. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_account_hardening.py +0 -0
  131. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_account_names.py +0 -0
  132. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_account_removal.py +0 -0
  133. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_activity_log.py +0 -0
  134. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_availability.py +0 -0
  135. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_bind_host.py +0 -0
  136. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_cert_provider.py +0 -0
  137. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_cluster.py +0 -0
  138. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_commands.py +0 -0
  139. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_console.py +0 -0
  140. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_contrib_drivers.py +0 -0
  141. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_default_rtl_sdr_schema.py +0 -0
  142. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_default_usrp_schema.py +0 -0
  143. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_default_wifi_schema.py +0 -0
  144. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_deployment_capabilities.py +0 -0
  145. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_detached_device_rpc.py +0 -0
  146. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_device_core_aliases.py +0 -0
  147. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_device_handles.py +0 -0
  148. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_device_init.py +0 -0
  149. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_devices_add_wizard.py +0 -0
  150. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_devices_list.py +0 -0
  151. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_diag.py +0 -0
  152. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_direct_fixed_port.py +0 -0
  153. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_direct_path_server.py +0 -0
  154. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_dynamic_v2.py +0 -0
  155. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_federated_direct_path.py +0 -0
  156. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_federated_dynamic_v2.py +0 -0
  157. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_federation_edges.py +0 -0
  158. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_first_run.py +0 -0
  159. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_gate_d_contracts.py +0 -0
  160. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_gate_e_federation.py +0 -0
  161. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_gate_e_proto.py +0 -0
  162. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_gate_f_enrollment.py +0 -0
  163. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_gate_g1_logging.py +0 -0
  164. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_gate_g_permissions.py +0 -0
  165. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_gate_h_devices.py +0 -0
  166. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_gate_i_direct_path.py +0 -0
  167. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_gen_certs.py +0 -0
  168. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_global_accounts_migration.py +0 -0
  169. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_global_home.py +0 -0
  170. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_global_v1_disposal.py +0 -0
  171. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_hackrf_schema.py +0 -0
  172. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_home_device_plane.py +0 -0
  173. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_host_direct.py +0 -0
  174. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_host_directory_store.py +0 -0
  175. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_host_dynamic_v2.py +0 -0
  176. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_idl_schema.py +0 -0
  177. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_lab_logins.py +0 -0
  178. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_panel.py +0 -0
  179. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_peer_transport.py +0 -0
  180. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_pluto_compat.py +0 -0
  181. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_pluto_poe.py +0 -0
  182. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_process_arg.py +0 -0
  183. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_proxy_lifecycle.py +0 -0
  184. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_remote_admin.py +0 -0
  185. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_reservation_cancel.py +0 -0
  186. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_reservation_handoff.py +0 -0
  187. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_reservation_policy_b.py +0 -0
  188. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_reservation_timestamps.py +0 -0
  189. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_rpc_manager_batch.py +0 -0
  190. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_rpc_manager_calln.py +0 -0
  191. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_rpc_manager_host_legacy.py +0 -0
  192. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_rpc_manager_rx_n.py +0 -0
  193. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_rtl_sdr_schema.py +0 -0
  194. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_schema_registry.py +0 -0
  195. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_soak_federation.py +0 -0
  196. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_startup_check.py +0 -0
  197. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_ti_mmwave_hardware.py +0 -0
  198. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_ti_mmwave_schema.py +0 -0
  199. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_user_group_cli.py +0 -0
  200. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_usrp_b205mini_uhd410_hardware.py +0 -0
  201. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_usrp_client_server_hardware.py +0 -0
  202. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_usrp_client_server_localhost.py +0 -0
  203. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_usrp_n210_client_server_hardware.py +0 -0
  204. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_usrp_preload.py +0 -0
  205. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_usrp_profiles.py +0 -0
  206. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_usrp_remoterf_rx_hardware.py +0 -0
  207. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_usrp_uhd410_hardware.py +0 -0
  208. {remoterf_server-1.7.14 → remoterf_server-1.7.16}/tests/test_wipe.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remoteRF-server
3
- Version: 1.7.14
3
+ Version: 1.7.16
4
4
  Summary: RemoteRF server-side control package
5
5
  License: GPL-3.0-or-later
6
6
  Classifier: Programming Language :: Python :: 3
@@ -10,7 +10,7 @@ Requires-Python: >=3.10
10
10
  Description-Content-Type: text/markdown
11
11
  License-File: LICENSE
12
12
  Requires-Dist: remoterf-federation-core==0.22.0
13
- Requires-Dist: remoterf-device-core==0.1.2
13
+ Requires-Dist: remoterf-device-core==0.1.3
14
14
  Requires-Dist: grpcio<2.0.0,>=1.81.1
15
15
  Requires-Dist: protobuf<7.0.0,>=6.33.5
16
16
  Requires-Dist: numpy
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "remoteRF-server"
7
- version = "1.7.14"
7
+ version = "1.7.16"
8
8
  description = "RemoteRF server-side control package"
9
9
  license = { text = "GPL-3.0-or-later" }
10
10
  requires-python = ">=3.10"
11
11
  dependencies = [
12
12
  "remoterf-federation-core==0.22.0",
13
- "remoterf-device-core==0.1.2",
13
+ "remoterf-device-core==0.1.3",
14
14
  "grpcio>=1.81.1,<2.0.0",
15
15
  "protobuf>=6.33.5,<7.0.0",
16
16
  "numpy",
@@ -48,7 +48,7 @@ TIERS = {
48
48
  "reservations list": READ, "groups list": READ, "codes list": READ, "hosts status": READ,
49
49
  "hosts tokens": READ, "certs list": READ, "config list": READ, "global status": READ,
50
50
  "cluster list": READ, "cluster info": READ, "users prune-proxies": READ,
51
- "cluster codes list": READ, "schema browse": READ,
51
+ "cluster codes list": READ, "schema browse": READ, "schema served": READ,
52
52
  # write
53
53
  "devices add": WRITE, "devices remove": WRITE, "devices rename": WRITE, "groups create": WRITE,
54
54
  "groups edit": WRITE, "groups public": WRITE, "groups share": WRITE, "codes create": WRITE,
@@ -162,6 +162,7 @@ def _table() -> list[Command]:
162
162
  # ---- driver schemas approved on RemoteRF Global (server/schema_registry.py) ----
163
163
  Command(("schema browse",), "Driver schemas approved on RemoteRF Global: schema browse [text]", _lazy(".schema_registry", "cmd_browse"), "Schemas"),
164
164
  Command(("schema download",), "Save one to the drivers folder, hash checked: schema download <name|hash> [-y]", _lazy(".schema_registry", "cmd_download"), "Schemas"),
165
+ Command(("schema served",), "Which file serves a driver type, its hash, how each drivers file loaded: schema served <type> [--json]", _daemon("cmd_schema_served"), "Schemas", where=D),
165
166
  # ---- clusters (sharing devices with other deployments) ----
166
167
  Command(("cluster list",), "Clusters shared with other deployments", _federation("list"), "Clusters"),
167
168
  Command(("cluster info",), "Every deployment, shared group and device in a cluster", _federation("info"), "Clusters"),
@@ -18,6 +18,7 @@
18
18
  from __future__ import annotations
19
19
 
20
20
  import importlib
21
+ import inspect
21
22
  import logging
22
23
  import os
23
24
  import re
@@ -35,7 +36,8 @@ from contextlib import contextmanager
35
36
  from remoterf_federation_core import is_device_handle
36
37
 
37
38
  from ..common.utils import validate_token, get_remoterf_root, get_drivers_dir, device_name_key
38
- from ..common.idl.schema import get_driver_class, load_drivers
39
+ from ..common.idl.schema import (OVERRIDE_PREFIX, get_driver_class, last_load_results, load_drivers,
40
+ override_stale)
39
41
  from ..host import host_tunnel_server as hts
40
42
 
41
43
  logger = logging.getLogger(__name__)
@@ -1384,6 +1386,33 @@ def acquire_device_by_gid(gid: int) -> Iterator[Tuple[int, object]]:
1384
1386
  lock.release()
1385
1387
 
1386
1388
 
1389
+ def schema_served(device_type: str) -> Dict[str, Any]:
1390
+ """`schema served <type>`: the file whose class serves a type now, its v1 hash, and how each drivers file loaded."""
1391
+ out: Dict[str, Any] = {"type": device_type, "hash": None, "module": None, "override": False,
1392
+ "source_sha256": None, "loads": last_load_results()}
1393
+ try:
1394
+ cls = get_driver_class(device_type)
1395
+ out["module"] = os.path.abspath(inspect.getfile(cls))
1396
+ except (KeyError, TypeError):
1397
+ return out
1398
+ out["override"] = Path(out["module"]).name.startswith(OVERRIDE_PREFIX)
1399
+ try:
1400
+ out["hash"] = cls().get_idl_hash()
1401
+ except Exception:
1402
+ pass
1403
+ try:
1404
+ out["source_sha256"] = _driver_sha256(Path(out["module"]))
1405
+ except OSError:
1406
+ pass
1407
+ return out
1408
+
1409
+
1410
+ def driver_overrides() -> List[Dict[str, Any]]:
1411
+ """The @ops-rrf overrides in the drivers folder, stale ones included (they do not load)."""
1412
+ return [{"type": f.stem[len(OVERRIDE_PREFIX):], "file": f.name, "stale": override_stale(f)}
1413
+ for f in sorted(get_drivers_dir().glob(f"{OVERRIDE_PREFIX}*.py"))]
1414
+
1415
+
1387
1416
  def device_inventory_snapshot() -> Dict[int, Dict[str, object]]:
1388
1417
  """Structured inventory for federated discovery.
1389
1418
 
@@ -64,7 +64,9 @@ deployments), what devices it has, how many reservations are active, how many
64
64
  reservations have been made and the total time reserved so far (two numbers),
65
65
  and your LAN address. For the devices you share on Global it also sends how
66
66
  many reservations start in each hour of the day and on each day of the week
67
- (UTC), and the reservations and time reserved per device type (counts only).
67
+ (UTC), the reservations and time reserved per device type, and for each
68
+ device shared on Global its name and its daily reservation counts and time
69
+ reserved over the last 31 days (counts only).
68
70
  Nothing else leaves your server -- no credentials, no user or reservation
69
71
  details -- and the connection only goes outward. Global publishes the network
70
72
  totals, these breakdowns, and each deployment's name, version, devices, when
@@ -867,24 +869,41 @@ def _device_type(item: dict) -> str:
867
869
  return str(item.get("device_type") or "unknown").lower()
868
870
 
869
871
 
872
+ def _host_devices() -> dict:
873
+ """Device id -> DeviceInfo from the Host directory `serverrf status` lists."""
874
+ try:
875
+ from ..host import host_tunnel_server as hts
876
+
877
+ directory = hts.get_tunnel_registry().device_directory_cached(ttl_ms=0)
878
+ except Exception: # noqa: BLE001 - drivers are optional at report time
879
+ return {}
880
+ return {int(device_id): info for device_id, (_host_id, info, _active) in directory.items()
881
+ if str(device_id).isdigit()}
882
+
883
+
870
884
  def _device_types() -> dict:
871
885
  """Device id -> type: devices.yml entries, then the Host directory `serverrf status` lists.
872
886
 
873
887
  Types are bounded the way Global accepts them: a Host names its own kinds.
874
888
  """
875
889
  types = {int(item["device_id"]): _device_type(item) for item in _registered_devices()}
876
- try:
877
- from ..host import host_tunnel_server as hts
878
-
879
- directory = hts.get_tunnel_registry().device_directory_cached(ttl_ms=0)
880
- except Exception: # noqa: BLE001 - drivers are optional at report time
881
- directory = {}
882
- for device_id, (_host_id, info, _active) in directory.items():
883
- if str(device_id).isdigit():
884
- types.setdefault(int(device_id), str(getattr(info, "kind", "") or "").strip().lower() or "unknown")
890
+ for device_id, info in _host_devices().items():
891
+ types.setdefault(device_id, str(getattr(info, "kind", "") or "").strip().lower() or "unknown")
885
892
  return {gid: re.sub(r"[^a-z0-9_]", "_", kind)[:64] for gid, kind in types.items()}
886
893
 
887
894
 
895
+ def _device_names() -> dict:
896
+ """Device id -> the name the devices list shows: devices.yml, then the Host's label.
897
+
898
+ Printable and at most 64 characters, the way Global accepts them.
899
+ """
900
+ names = {int(item["device_id"]): item.get("name") for item in _registered_devices()}
901
+ for device_id, info in _host_devices().items():
902
+ names.setdefault(device_id, getattr(info, "label", ""))
903
+ return {gid: "".join(filter(str.isprintable, str(name or ""))).strip()[:64].rstrip() or f"device {gid}"
904
+ for gid, name in names.items()}
905
+
906
+
888
907
  def device_summary() -> list:
889
908
  """One entry per registered device: its type and whether it is connected.
890
909
 
@@ -901,13 +920,15 @@ def device_summary() -> list:
901
920
 
902
921
  def usage_summary() -> dict:
903
922
  """Reservations made on the devices shared on Global: by the UTC hour and
904
- weekday they start, and reservations and seconds reserved per device type.
923
+ weekday they start, reservations and seconds reserved per device type, and
924
+ per device by the UTC day they start over the last 31 days.
905
925
 
906
- Counts only (ReservationBuckets); all zero when nothing is shared there.
926
+ Counts only (ReservationBuckets, ReservationDays); all zero when nothing is shared there.
907
927
  """
928
+ from datetime import datetime, timedelta, timezone
908
929
  from .federation import cluster
909
930
 
910
- usage = {"by_hour": [0] * 24, "by_weekday": [0] * 7, "by_type": {}}
931
+ usage = {"by_hour": [0] * 24, "by_weekday": [0] * 7, "by_type": {}, "by_device": []}
911
932
  try:
912
933
  shared = {int(d) for group in cluster.groups(cluster.GLOBAL) for d in group["devices"]}
913
934
  except Exception: # noqa: BLE001 - not in the global cluster (yet), or its state is unreadable
@@ -927,6 +948,24 @@ def usage_summary() -> dict:
927
948
  usage["by_hour" if bucket[0] == "h" else "by_weekday"][int(bucket[1:])] += n
928
949
  busiest = sorted((kv for kv in by_type.items() if kv[1]["reservations"]), key=lambda kv: -kv[1]["reservations"])
929
950
  usage["by_type"] = dict(busiest[:64]) # Global takes at most 64 types
951
+
952
+ # Global takes at most 128 devices and 1000 days in all: the oldest days go first.
953
+ today = datetime.now(timezone.utc).date()
954
+ by_device, entries = {}, 0
955
+ for device_id, day, n, seconds in _rows(
956
+ _db_dir() / "reservations.db",
957
+ "SELECT device_id, day, reservations, CAST(ROUND(seconds) AS INTEGER) FROM ReservationDays "
958
+ "WHERE day BETWEEN ? AND ? ORDER BY day DESC, device_id",
959
+ (str(today - timedelta(days=30)), str(today))):
960
+ if device_id not in shared or not (n or seconds) or (device_id not in by_device and len(by_device) == 128):
961
+ continue
962
+ if entries == 1000:
963
+ break
964
+ by_device.setdefault(device_id, {})[day] = [min(n, 2**31), min(seconds, 2**31)]
965
+ entries += 1
966
+ names = _device_names()
967
+ usage["by_device"] = [{"id": gid, "name": names.get(gid, f"device {gid}"), "type": types.get(gid, "unknown"),
968
+ "days": days} for gid, days in sorted(by_device.items())]
930
969
  return usage
931
970
 
932
971
 
@@ -980,15 +1019,17 @@ def send_status() -> None:
980
1019
 
981
1020
  payload = status_payload()
982
1021
  # A Global older than a field refuses the note: drop the newest first
983
- # (`usage`, then `reservations`), then `proxies`/`reserved_s`, as the note
984
- # was before each of them.
985
- steps = ((), ("usage",), ("usage", "reservations"), ("usage", "reservations", "proxies", "reserved_s"))
986
- for drop in steps:
1022
+ # (`usage.by_device`, `usage`, then `reservations`), then
1023
+ # `proxies`/`reserved_s`, as the note was before each of them.
1024
+ steps = [payload, dict(payload, usage={k: v for k, v in payload["usage"].items() if k != "by_device"})]
1025
+ for drop in (("usage",), ("usage", "reservations"), ("usage", "reservations", "proxies", "reserved_s")):
1026
+ steps.append({k: v for k, v in payload.items() if k not in drop})
1027
+ for status in steps:
987
1028
  try:
988
- call("POST", REPORT_PATH, {"kind": "status", "status": {k: v for k, v in payload.items() if k not in drop}})
1029
+ call("POST", REPORT_PATH, {"kind": "status", "status": status})
989
1030
  break
990
1031
  except GlobalError as exc:
991
- if exc.code != "invalid_status" or drop is steps[-1]:
1032
+ if exc.code != "invalid_status" or status is steps[-1]:
992
1033
  raise
993
1034
  update(GLOBAL_LAST_REPORT_AT=datetime.now(timezone.utc).isoformat())
994
1035
 
@@ -55,7 +55,8 @@ from .host_dynamic_v2 import host_session_proxy
55
55
  from .acc_perms import perms
56
56
  from ..common.utils import *
57
57
  from .device_manager import (close_local_devices, reload_devices, get_registered_devices,
58
- get_all_devices_str, handle_report, device_inventory_snapshot)
58
+ get_all_devices_str, handle_report, device_inventory_snapshot,
59
+ driver_overrides, schema_served)
59
60
  from .user_group_cli import make_user_group_local, edit_user_group_local, list_user_groups_local, remove_user_group_local, make_enrollment_codes_local, remove_enrollment_code_local, list_enrollment_codes_local, export_user_groups_csv_local, export_enrollment_codes_csv_local
60
61
  from .user_group_handler import start_usergroup_cleanup_loop
61
62
  from .global_onboarding import start_agent
@@ -1205,6 +1206,7 @@ def _status_json() -> dict:
1205
1206
  "devices_total": len(devices),
1206
1207
  "devices_online": sum(bool(item["online"]) for item in devices.values()),
1207
1208
  "reservations_active": int(active),
1209
+ "driver_overrides": driver_overrides(),
1208
1210
  }
1209
1211
 
1210
1212
 
@@ -1457,6 +1459,27 @@ def cmd_stats_purge(argv):
1457
1459
  def cmd_devices_list(argv): get_devices(json_output="--json" in argv)
1458
1460
 
1459
1461
 
1462
+ def cmd_schema_served(argv):
1463
+ words = [a for a in argv if a != "--json"]
1464
+ if len(words) != 1 or not re.fullmatch(r"[a-z0-9_]{1,64}", words[0]):
1465
+ print("Usage: schema served <type> [--json]")
1466
+ return 2
1467
+ value = schema_served(words[0])
1468
+ if "--json" in argv:
1469
+ from .json_output import print_json
1470
+
1471
+ print_json(value)
1472
+ return 0
1473
+ print(f"{value['type']}: {value['hash'] or 'no driver registered'}")
1474
+ if value["module"]:
1475
+ print(f" served by {value['module']}{' (rrfops override)' if value['override'] else ''}")
1476
+ print(f" file sha256 {value['source_sha256'] or '-'}")
1477
+ for load in value["loads"]:
1478
+ if not load["ok"]:
1479
+ print(f" {load['file']}: {load['error']}")
1480
+ return 0
1481
+
1482
+
1460
1483
  def cmd_diag(argv):
1461
1484
  from . import diag
1462
1485
 
@@ -258,9 +258,14 @@ class ReservationHandler:
258
258
  # Reported to Global as `usage`, for the devices shared there. Seeded
259
259
  # only into an empty table: the bucket names follow the process TZ, so a
260
260
  # restart under another TZ must not count the current rows again.
261
+ # ReservationDays keeps them per UTC date of the start (`usage.by_device`).
261
262
  hour = lambda r: f"'h' || strftime('%H', {r}.start_time, 'utc')" # noqa: E731
262
263
  day = lambda r: f"'d' || ((CAST(strftime('%w', {r}.start_time, 'utc') AS INTEGER) + 6) % 7)" # noqa: E731
264
+ utc_date = lambda r: f"date({r}.start_time, 'utc')" # noqa: E731
263
265
  length = lambda r: f"(julianday({r}.end_time) - julianday({r}.start_time)) * 86400" # noqa: E731
266
+ cancel = '''reservations = MAX(reservations - (OLD.start_time > datetime('now', 'localtime')), 0),
267
+ seconds = MAX(seconds - MAX(0,
268
+ (julianday(OLD.end_time) - julianday(MAX(OLD.start_time, datetime('now', 'localtime')))) * 86400), 0)'''
264
269
  self.cursor.executescript(f'''
265
270
  BEGIN IMMEDIATE;
266
271
  CREATE TABLE IF NOT EXISTS ReservationBuckets (device_id INTEGER NOT NULL, bucket TEXT NOT NULL,
@@ -283,12 +288,26 @@ class ReservationHandler:
283
288
  END;
284
289
  CREATE TRIGGER IF NOT EXISTS reservation_buckets_del AFTER DELETE ON Reservations
285
290
  WHEN OLD.device_id < 1000000 BEGIN
286
- UPDATE ReservationBuckets SET
287
- reservations = MAX(reservations - (OLD.start_time > datetime('now', 'localtime')), 0),
288
- seconds = MAX(seconds - MAX(0,
289
- (julianday(OLD.end_time) - julianday(MAX(OLD.start_time, datetime('now', 'localtime')))) * 86400), 0)
291
+ UPDATE ReservationBuckets SET {cancel}
290
292
  WHERE device_id = OLD.device_id AND bucket IN ('all', {hour("OLD")}, {day("OLD")});
291
293
  END;
294
+ CREATE TABLE IF NOT EXISTS ReservationDays (device_id INTEGER NOT NULL, day TEXT NOT NULL,
295
+ reservations INTEGER NOT NULL, seconds REAL NOT NULL, PRIMARY KEY (device_id, day));
296
+ INSERT INTO ReservationDays
297
+ SELECT device_id, {utc_date("r")} AS day, COUNT(*), SUM({length("r")}) FROM Reservations r
298
+ WHERE device_id < 1000000 AND NOT EXISTS (SELECT 1 FROM ReservationDays)
299
+ GROUP BY device_id, day;
300
+ CREATE TRIGGER IF NOT EXISTS reservation_days_ins AFTER INSERT ON Reservations
301
+ WHEN NEW.device_id < 1000000 BEGIN
302
+ INSERT INTO ReservationDays VALUES (NEW.device_id, {utc_date("NEW")}, 1, {length("NEW")})
303
+ ON CONFLICT (device_id, day) DO UPDATE
304
+ SET reservations = reservations + 1, seconds = seconds + excluded.seconds;
305
+ END;
306
+ CREATE TRIGGER IF NOT EXISTS reservation_days_del AFTER DELETE ON Reservations
307
+ WHEN OLD.device_id < 1000000 BEGIN
308
+ UPDATE ReservationDays SET {cancel}
309
+ WHERE device_id = OLD.device_id AND day = {utc_date("OLD")};
310
+ END;
292
311
  COMMIT;
293
312
  ''')
294
313
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remoteRF-server
3
- Version: 1.7.14
3
+ Version: 1.7.16
4
4
  Summary: RemoteRF server-side control package
5
5
  License: GPL-3.0-or-later
6
6
  Classifier: Programming Language :: Python :: 3
@@ -10,7 +10,7 @@ Requires-Python: >=3.10
10
10
  Description-Content-Type: text/markdown
11
11
  License-File: LICENSE
12
12
  Requires-Dist: remoterf-federation-core==0.22.0
13
- Requires-Dist: remoterf-device-core==0.1.2
13
+ Requires-Dist: remoterf-device-core==0.1.3
14
14
  Requires-Dist: grpcio<2.0.0,>=1.81.1
15
15
  Requires-Dist: protobuf<7.0.0,>=6.33.5
16
16
  Requires-Dist: numpy
@@ -1,5 +1,5 @@
1
1
  remoterf-federation-core==0.22.0
2
- remoterf-device-core==0.1.2
2
+ remoterf-device-core==0.1.3
3
3
  grpcio<2.0.0,>=1.81.1
4
4
  protobuf<7.0.0,>=6.33.5
5
5
  numpy
@@ -207,7 +207,7 @@ class ReservationCountTests(unittest.TestCase):
207
207
 
208
208
 
209
209
  class ReservationBucketsTests(unittest.TestCase):
210
- """Per device, by UTC hour and weekday of the start: counted like the totals above."""
210
+ """Per device, by UTC hour, weekday and date of the start: counted like the totals above."""
211
211
 
212
212
  def setUp(self):
213
213
  import time
@@ -244,6 +244,11 @@ class ReservationBucketsTests(unittest.TestCase):
244
244
  rows = db.execute("SELECT bucket, reservations, seconds FROM ReservationBuckets WHERE device_id = ?", (device_id,))
245
245
  return {bucket: (n, round(seconds)) for bucket, n, seconds in rows}
246
246
 
247
+ @staticmethod
248
+ def days(db, device_id):
249
+ rows = db.execute("SELECT day, reservations, seconds FROM ReservationDays WHERE device_id = ?", (device_id,))
250
+ return {day: (n, round(seconds)) for day, n, seconds in rows}
251
+
247
252
  def test_buckets_by_utc_hour_and_weekday_survive_purge_and_skip_partner_devices(self):
248
253
  from datetime import datetime
249
254
  from remoteRF_server.server.federation.home_devices import ALIAS_BASE
@@ -256,8 +261,12 @@ class ReservationBucketsTests(unittest.TestCase):
256
261
  expected = {"all": (2, 3 * 3600), "h20": (1, 3600), "d6": (1, 3600), "h06": (1, 2 * 3600), "d2": (1, 2 * 3600)}
257
262
  self.assertEqual(self.buckets(db, 1), expected)
258
263
  self.assertEqual(self.buckets(db, ALIAS_BASE), {})
264
+ days = {"2026-11-01": (1, 3600), "2026-11-04": (1, 2 * 3600)} # the first starts the UTC day before
265
+ self.assertEqual(self.days(db, 1), days)
266
+ self.assertEqual(self.days(db, ALIAS_BASE), {})
259
267
  db.execute("DELETE FROM Reservations WHERE end_time < ?", (datetime.now(),)) # purge: no change
260
268
  self.assertEqual(self.buckets(db, 1), expected)
269
+ self.assertEqual(self.days(db, 1), days)
261
270
 
262
271
  def test_a_cancel_gives_back_like_the_totals_and_never_below_zero(self):
263
272
  from datetime import datetime, timedelta
@@ -268,17 +277,18 @@ class ReservationBucketsTests(unittest.TestCase):
268
277
  self.add(db, 2, now + timedelta(hours=1), now + timedelta(hours=3))
269
278
  self.add(db, 3, now - timedelta(hours=1), now + timedelta(hours=1))
270
279
  self.add(db, 4, now + timedelta(hours=1), now + timedelta(hours=2))
271
- values = lambda device_id: sorted(self.buckets(db, device_id).values()) # noqa: E731
272
- self.assertEqual(values(2), [(1, 2 * 3600)] * 3)
280
+ values = lambda gid: sorted([*self.buckets(db, gid).values(), *self.days(db, gid).values()]) # noqa: E731
281
+ self.assertEqual(values(2), [(1, 2 * 3600)] * 4)
273
282
  db.execute("DELETE FROM Reservations WHERE device_id = 2") # cancel before start: all given back
274
- self.assertEqual(values(2), [(0, 0)] * 3)
283
+ self.assertEqual(values(2), [(0, 0)] * 4)
275
284
  db.execute("DELETE FROM Reservations WHERE device_id = 3") # cancel after start: unused hour back
276
285
  for n, seconds in values(3):
277
286
  self.assertEqual(n, 1)
278
287
  self.assertAlmostEqual(seconds, 3600, delta=2)
279
- db.execute("UPDATE ReservationBuckets SET reservations = 0, seconds = 0 WHERE device_id = 4")
288
+ for table in ("ReservationBuckets", "ReservationDays"):
289
+ db.execute(f"UPDATE {table} SET reservations = 0, seconds = 0 WHERE device_id = 4")
280
290
  db.execute("DELETE FROM Reservations WHERE device_id = 4")
281
- self.assertEqual(values(4), [(0, 0)] * 3) # never negative
291
+ self.assertEqual(values(4), [(0, 0)] * 4) # never negative
282
292
 
283
293
  def test_buckets_seed_from_old_rows_once(self):
284
294
  import time
@@ -292,15 +302,33 @@ class ReservationBucketsTests(unittest.TestCase):
292
302
  self.add(db, 1, "2026-11-02 02:15:00", "2026-11-02 02:45:00")
293
303
  self.add(db, ALIAS_BASE, "2026-11-02 02:00:00", "2026-11-02 03:00:00")
294
304
  expected = {"all": (2, 5400), "h20": (2, 5400), "d6": (2, 5400)}
305
+ days = {"2026-11-01": (2, 5400)}
295
306
  self.handler()
296
307
  self.assertEqual(self.buckets(db, 1), expected)
297
308
  self.assertEqual(self.buckets(db, ALIAS_BASE), {})
309
+ self.assertEqual(self.days(db, 1), days)
310
+ self.assertEqual(self.days(db, ALIAS_BASE), {})
298
311
  self.handler() # a restart does not seed again
299
312
  self.assertEqual(self.buckets(db, 1), expected)
313
+ self.assertEqual(self.days(db, 1), days)
300
314
  with mock.patch.dict("os.environ", {"TZ": "UTC0"}):
301
315
  time.tzset()
302
- self.handler() # nor one under another TZ, where the rows map to h02/d0
316
+ self.handler() # nor one under another TZ, where the rows map to h02/d0/2026-11-02
303
317
  time.tzset()
304
318
  self.assertEqual(self.buckets(db, 1), expected)
319
+ self.assertEqual(self.days(db, 1), days)
305
320
  self.add(db, 1, "2026-11-02 02:20:00", "2026-11-02 02:50:00") # 20:50 UTC
306
321
  self.assertEqual(self.buckets(db, 1)["h20"], (3, 7200))
322
+ self.assertEqual(self.days(db, 1), {"2026-11-01": (3, 7200)})
323
+
324
+ def test_days_seed_into_an_existing_lab_that_already_has_buckets(self):
325
+ """A lab upgrading from 1.7.14 has ReservationBuckets but no ReservationDays yet."""
326
+ self.handler()
327
+ db = self.connect()
328
+ db.executescript("DROP TRIGGER reservation_days_ins; DROP TRIGGER reservation_days_del; "
329
+ "DROP TABLE ReservationDays;")
330
+ self.add(db, 1, "2026-11-04 12:00:00", "2026-11-04 13:00:00")
331
+ buckets = self.buckets(db, 1)
332
+ self.handler()
333
+ self.assertEqual(self.days(db, 1), {"2026-11-04": (1, 3600)})
334
+ self.assertEqual(self.buckets(db, 1), buckets)
@@ -509,7 +509,8 @@ class StatusReportTests(TunnelTests):
509
509
  self.assertEqual(payload["proxies"], 1)
510
510
  self.assertEqual(payload["active_reservations"], 1)
511
511
  self.assertEqual(payload["reservations"], 5)
512
- self.assertEqual(payload["usage"], {"by_hour": [0] * 24, "by_weekday": [0] * 7, "by_type": {}}) # not on global
512
+ self.assertEqual(payload["usage"], # not on global
513
+ {"by_hour": [0] * 24, "by_weekday": [0] * 7, "by_type": {}, "by_device": []})
513
514
  self.assertEqual(
514
515
  sorted(entry["type"] for entry in payload["devices"]),
515
516
  ["adalm_pluto", "usrp_2901"],
@@ -528,7 +529,8 @@ class StatusReportTests(TunnelTests):
528
529
  text = " ".join(g.CONSENT_TEXT.split())
529
530
  for item in ("name, version", "devices", "joined", "last online", "how many reservations",
530
531
  "time reserved, at https://{domain}/stats.json", "devices you share on Global",
531
- "each hour of the day", "each day of the week", "per device type", "these breakdowns"):
532
+ "each hour of the day", "each day of the week", "per device type", "these breakdowns",
533
+ "for each device shared on Global its name and its daily reservation counts and time reserved"):
532
534
  self.assertIn(item, text)
533
535
 
534
536
  def test_status_payload_survives_missing_state(self):
@@ -539,7 +541,7 @@ class StatusReportTests(TunnelTests):
539
541
  self.assertEqual(payload["proxies"], 0)
540
542
  self.assertEqual(payload["reservations"], 0)
541
543
  self.assertEqual(payload["devices"], [])
542
- self.assertEqual(payload["usage"], {"by_hour": [0] * 24, "by_weekday": [0] * 7, "by_type": {}})
544
+ self.assertEqual(payload["usage"], {"by_hour": [0] * 24, "by_weekday": [0] * 7, "by_type": {}, "by_device": []})
543
545
  self.assertIsNone(payload["cert_sha256"])
544
546
 
545
547
  def test_usage_counts_only_the_devices_shared_on_global(self):
@@ -568,7 +570,76 @@ class StatusReportTests(TunnelTests):
568
570
  "unknown": {"reservations": 1, "seconds": 60}})
569
571
 
570
572
  with mock.patch.object(cluster, "groups", side_effect=cluster.ClusterError("no cluster named global")):
571
- self.assertEqual(g.usage_summary(), {"by_hour": [0] * 24, "by_weekday": [0] * 7, "by_type": {}})
573
+ self.assertEqual(g.usage_summary(),
574
+ {"by_hour": [0] * 24, "by_weekday": [0] * 7, "by_type": {}, "by_device": []})
575
+
576
+ def days_table(self, rows):
577
+ import sqlite3
578
+
579
+ with sqlite3.connect(g.cfg_dir() / "db" / "reservations.db") as conn:
580
+ conn.execute("CREATE TABLE ReservationDays (device_id INTEGER, day TEXT, reservations INTEGER, "
581
+ "seconds REAL, PRIMARY KEY (device_id, day))")
582
+ conn.executemany("INSERT INTO ReservationDays VALUES (?,?,?,?)", rows)
583
+
584
+ def test_by_device_reports_the_last_31_days_of_shared_devices_by_name(self):
585
+ from datetime import datetime, timedelta, timezone
586
+ from remoteRF_server.server.federation import cluster
587
+
588
+ self.seed()
589
+ (g.cfg_dir() / "devices.yml").write_text(
590
+ "devices:\n"
591
+ " - {device_id: 0, device_type: adalm_pluto, name: Bench Pluto}\n"
592
+ " - {device_id: 1, device_type: usrp_2901, name: Not shared}\n"
593
+ " - {device_id: 2, device_type: adalm_pluto, name: \"\\e[31mRed\\nPluto \"}\n"
594
+ " - {device_id: 3, device_type: adalm_pluto, name: \"\\t\"}\n"
595
+ f" - {{device_id: 4, device_type: adalm_pluto, name: {'x' * 80}}}\n",
596
+ encoding="utf-8",
597
+ )
598
+ self.use_host_registry([("host-a", 200, "adalm_pluto_mimo_ykush")])
599
+ today = datetime.now(timezone.utc).date()
600
+ day = lambda n: str(today - timedelta(days=n)) # noqa: E731
601
+ self.days_table([
602
+ (0, day(0), 2, 5400.4), (0, day(30), 1, 60.0), (0, day(31), 9, 999.0), # day 31 is out of the window
603
+ (0, str(today + timedelta(days=1)), 1, 60.0), # not started yet
604
+ (0, day(3), 0, 0.2), # every reservation cancelled
605
+ (1, day(0), 4, 400.0), # not shared on Global
606
+ (2, day(1), 1, 30.0), (3, day(1), 0, 30.0), (4, day(1), 1, 0.0),
607
+ (5, day(2), 1, 60.0), # gone from devices.yml
608
+ (6, day(31), 1, 60.0), # nothing left in the window
609
+ (200, day(5), 3, 180.0),
610
+ ])
611
+ shared = [{"devices": [0, 2, 3, 4, 5, 6]}, {"devices": [200]}]
612
+ with mock.patch.object(cluster, "groups", return_value=shared):
613
+ by_device = g.usage_summary()["by_device"]
614
+ self.assertEqual(by_device, [
615
+ {"id": 0, "name": "Bench Pluto", "type": "adalm_pluto", "days": {day(0): [2, 5400], day(30): [1, 60]}},
616
+ {"id": 2, "name": "[31mRedPluto", "type": "adalm_pluto", "days": {day(1): [1, 30]}},
617
+ {"id": 3, "name": "device 3", "type": "adalm_pluto", "days": {day(1): [0, 30]}},
618
+ {"id": 4, "name": "x" * 64, "type": "adalm_pluto", "days": {day(1): [1, 0]}},
619
+ {"id": 5, "name": "device 5", "type": "unknown", "days": {day(2): [1, 60]}},
620
+ {"id": 200, "name": "Pluto 200", "type": "adalm_pluto_mimo_ykush", "days": {day(5): [3, 180]}},
621
+ ])
622
+
623
+ # A rename shows on the next note.
624
+ path = g.cfg_dir() / "devices.yml"
625
+ path.write_text(path.read_text(encoding="utf-8").replace("Bench Pluto", "Bench A"), encoding="utf-8")
626
+ with mock.patch.object(cluster, "groups", return_value=shared):
627
+ self.assertEqual(g.usage_summary()["by_device"][0]["name"], "Bench A")
628
+
629
+ def test_by_device_drops_the_oldest_days_first_to_fit_globals_caps(self):
630
+ from datetime import datetime, timedelta, timezone
631
+ from remoteRF_server.server.federation import cluster
632
+
633
+ self.seed()
634
+ today = datetime.now(timezone.utc).date()
635
+ self.days_table([(gid, str(today - timedelta(days=n)), 1, 60.0) for gid in range(130) for n in range(10)])
636
+ with mock.patch.object(cluster, "groups", return_value=[{"devices": list(range(130))}]):
637
+ by_device = g.usage_summary()["by_device"]
638
+ self.assertEqual([entry["id"] for entry in by_device], list(range(128)))
639
+ days = [d for entry in by_device for d in entry["days"]]
640
+ self.assertEqual(len(days), 1000)
641
+ self.assertEqual(min(days), str(today - timedelta(days=7))) # days 8 and 9 dropped, day 7 cut short
642
+ self.assertEqual(sum(d == min(days) for d in days), 1000 - 7 * 128)
572
643
 
573
644
  def use_host_registry(self, persisted=(), online_hosts=()):
574
645
  """The Host directory as the server loads it at boot, with online_hosts connected."""
@@ -647,6 +718,7 @@ class StatusReportTests(TunnelTests):
647
718
  with mock.patch.object(g, "call", side_effect=old_global):
648
719
  g.send_status()
649
720
  self.assertEqual(sent, [
721
+ set(g.STATUS_FIELDS),
650
722
  set(g.STATUS_FIELDS),
651
723
  set(g.STATUS_FIELDS) - {"usage"},
652
724
  set(g.STATUS_FIELDS) - {"usage", "reservations"},
@@ -665,6 +737,7 @@ class StatusReportTests(TunnelTests):
665
737
  with mock.patch.object(g, "call", side_effect=global_with_reserved_s):
666
738
  g.send_status()
667
739
  self.assertEqual(sent, [
740
+ set(g.STATUS_FIELDS),
668
741
  set(g.STATUS_FIELDS),
669
742
  set(g.STATUS_FIELDS) - {"usage"},
670
743
  set(g.STATUS_FIELDS) - {"usage", "reservations"},
@@ -681,7 +754,21 @@ class StatusReportTests(TunnelTests):
681
754
 
682
755
  with mock.patch.object(g, "call", side_effect=global_with_reservations):
683
756
  g.send_status()
684
- self.assertEqual(sent, [set(g.STATUS_FIELDS), set(g.STATUS_FIELDS) - {"usage"}])
757
+ self.assertEqual(sent, [set(g.STATUS_FIELDS), set(g.STATUS_FIELDS), set(g.STATUS_FIELDS) - {"usage"}])
758
+
759
+ def test_status_drops_only_by_device_for_a_global_that_predates_it(self):
760
+ self.activate()
761
+ sent = []
762
+
763
+ def global_with_usage(method, path, body):
764
+ sent.append(body["status"])
765
+ if "by_device" in body["status"]["usage"]:
766
+ raise g.GlobalError("invalid_status")
767
+
768
+ with mock.patch.object(g, "call", side_effect=global_with_usage):
769
+ g.send_status()
770
+ self.assertEqual([set(status) for status in sent], [set(g.STATUS_FIELDS)] * 2)
771
+ self.assertEqual(sent[1]["usage"], {"by_hour": [0] * 24, "by_weekday": [0] * 7, "by_type": {}})
685
772
 
686
773
  def test_status_gives_up_when_even_the_oldest_note_is_refused(self):
687
774
  self.activate()
@@ -689,7 +776,7 @@ class StatusReportTests(TunnelTests):
689
776
  with mock.patch.object(g, "call", side_effect=g.GlobalError("invalid_status")) as call:
690
777
  with self.assertRaises(g.GlobalError):
691
778
  g.send_status()
692
- self.assertEqual(call.call_count, 4)
779
+ self.assertEqual(call.call_count, 5)
693
780
 
694
781
  def test_reports_only_flow_once_active(self):
695
782
  self.join()