mx-remote 5.1.0__tar.gz → 5.2.0__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 (211) hide show
  1. {mx_remote-5.1.0 → mx_remote-5.2.0}/PKG-INFO +1 -1
  2. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/const.py +12 -1
  3. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/const.pyi +2 -0
  4. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/Constants.py +4 -0
  5. mx_remote-5.2.0/mx_remote/proto/FrameEDIDProfile.py +53 -0
  6. mx_remote-5.2.0/mx_remote/proto/FrameEDIDProfile.pyi +15 -0
  7. mx_remote-5.2.0/mx_remote/proto/FrameSetName.py +62 -0
  8. mx_remote-5.2.0/mx_remote/proto/FrameSetName.pyi +17 -0
  9. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/remote/Remote.py +72 -19
  10. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/remote/Remote.pyi +1 -1
  11. mx_remote-5.2.0/tests/entry.py +59 -0
  12. mx_remote-5.2.0/tests/hello.py +197 -0
  13. mx_remote-5.2.0/tests/proto_stamp.py +122 -0
  14. mx_remote-5.2.0/tests/roundtrip.py +176 -0
  15. {mx_remote-5.1.0 → mx_remote-5.2.0}/tests/run.py +3 -0
  16. mx_remote-5.1.0/mx_remote/proto/FrameEDIDProfile.py +0 -23
  17. mx_remote-5.1.0/mx_remote/proto/FrameEDIDProfile.pyi +0 -6
  18. mx_remote-5.1.0/mx_remote/proto/FrameSetName.py +0 -26
  19. mx_remote-5.1.0/mx_remote/proto/FrameSetName.pyi +0 -6
  20. mx_remote-5.1.0/tests/proto_stamp.py +0 -60
  21. {mx_remote-5.1.0 → mx_remote-5.2.0}/.gitignore +0 -0
  22. {mx_remote-5.1.0 → mx_remote-5.2.0}/CLAUDE.md +0 -0
  23. {mx_remote-5.1.0 → mx_remote-5.2.0}/LICENSE +0 -0
  24. {mx_remote-5.1.0 → mx_remote-5.2.0}/README.md +0 -0
  25. {mx_remote-5.1.0 → mx_remote-5.2.0}/hatch_build.py +0 -0
  26. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/Interface.py +0 -0
  27. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/Interface.pyi +0 -0
  28. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/Uid.py +0 -0
  29. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/Uid.pyi +0 -0
  30. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/__init__.py +0 -0
  31. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/__init__.pyi +0 -0
  32. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/main.py +0 -0
  33. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/main.pyi +0 -0
  34. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/BayConfig.py +0 -0
  35. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/BayConfig.pyi +0 -0
  36. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/Constants.pyi +0 -0
  37. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/Data.py +0 -0
  38. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/Data.pyi +0 -0
  39. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/Factory.py +0 -0
  40. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/Factory.pyi +0 -0
  41. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameAmpDolbySettings.py +0 -0
  42. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameAmpDolbySettings.pyi +0 -0
  43. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameAmpZoneSettings.py +0 -0
  44. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameAmpZoneSettings.pyi +0 -0
  45. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameAudioClip.py +0 -0
  46. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameAudioClip.pyi +0 -0
  47. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameAudioSetRoute.py +0 -0
  48. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameAudioSetRoute.pyi +0 -0
  49. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameBase.py +0 -0
  50. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameBase.pyi +0 -0
  51. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameBayConfig.py +0 -0
  52. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameBayConfig.pyi +0 -0
  53. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameBayConfigSecondary.py +0 -0
  54. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameBayConfigSecondary.pyi +0 -0
  55. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameBayHide.py +0 -0
  56. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameBayHide.pyi +0 -0
  57. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameBayStatus.py +0 -0
  58. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameBayStatus.pyi +0 -0
  59. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameConnectStatus.py +0 -0
  60. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameConnectStatus.pyi +0 -0
  61. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameDebug.py +0 -0
  62. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameDebug.pyi +0 -0
  63. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameDiscover.py +0 -0
  64. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameDiscover.pyi +0 -0
  65. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameEDID.py +0 -0
  66. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameEDID.pyi +0 -0
  67. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameFactoryReset.py +0 -0
  68. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameFactoryReset.pyi +0 -0
  69. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameFilterStatus.py +0 -0
  70. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameFilterStatus.pyi +0 -0
  71. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameFirmwareVersion.py +0 -0
  72. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameFirmwareVersion.pyi +0 -0
  73. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameHeader.py +0 -0
  74. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameHeader.pyi +0 -0
  75. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameHello.py +0 -0
  76. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameHello.pyi +0 -0
  77. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameLinks.py +0 -0
  78. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameLinks.pyi +0 -0
  79. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameMeshOperation.py +0 -0
  80. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameMeshOperation.pyi +0 -0
  81. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameMirrorStatus.py +0 -0
  82. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameMirrorStatus.pyi +0 -0
  83. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameMonitoringPulse.py +0 -0
  84. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameMonitoringPulse.pyi +0 -0
  85. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameNetworkStatus.py +0 -0
  86. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameNetworkStatus.pyi +0 -0
  87. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FramePDUState.py +0 -0
  88. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FramePDUState.pyi +0 -0
  89. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FramePowerChange.py +0 -0
  90. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FramePowerChange.pyi +0 -0
  91. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameRCAction.py +0 -0
  92. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameRCAction.pyi +0 -0
  93. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameRCIr.py +0 -0
  94. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameRCIr.pyi +0 -0
  95. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameRCKey.py +0 -0
  96. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameRCKey.pyi +0 -0
  97. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameRCSettings.py +0 -0
  98. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameRCSettings.pyi +0 -0
  99. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameReboot.py +0 -0
  100. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameReboot.pyi +0 -0
  101. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameRoutingChange.py +0 -0
  102. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameRoutingChange.pyi +0 -0
  103. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameSetInstaller.py +0 -0
  104. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameSetInstaller.pyi +0 -0
  105. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameSetRoute.py +0 -0
  106. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameSetRoute.pyi +0 -0
  107. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameSetupStatus.py +0 -0
  108. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameSetupStatus.pyi +0 -0
  109. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameSignalStatus.py +0 -0
  110. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameSignalStatus.pyi +0 -0
  111. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameSignalStatusNew.py +0 -0
  112. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameSignalStatusNew.pyi +0 -0
  113. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameSysTemperature.py +0 -0
  114. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameSysTemperature.pyi +0 -0
  115. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameSystemStatus.py +0 -0
  116. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameSystemStatus.pyi +0 -0
  117. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameTXRCAction.py +0 -0
  118. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameTXRCAction.pyi +0 -0
  119. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameTXRCKey.py +0 -0
  120. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameTXRCKey.pyi +0 -0
  121. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameTopology.py +0 -0
  122. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameTopology.pyi +0 -0
  123. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameTxIR.py +0 -0
  124. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameTxIR.pyi +0 -0
  125. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameUpgradeFPGA.py +0 -0
  126. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameUpgradeFPGA.pyi +0 -0
  127. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameV2IPAudio.py +0 -0
  128. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameV2IPAudio.pyi +0 -0
  129. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameV2IPBayMapping.py +0 -0
  130. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameV2IPBayMapping.pyi +0 -0
  131. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameV2IPDetectBays.py +0 -0
  132. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameV2IPDetectBays.pyi +0 -0
  133. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameV2IPDeviceConfiguration.py +0 -0
  134. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameV2IPDeviceConfiguration.pyi +0 -0
  135. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameV2IPLink.py +0 -0
  136. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameV2IPLink.pyi +0 -0
  137. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameV2IPManualSourceSwitch.py +0 -0
  138. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameV2IPManualSourceSwitch.pyi +0 -0
  139. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameV2IPMultiviewer.py +0 -0
  140. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameV2IPMultiviewer.pyi +0 -0
  141. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameV2IPPowerSave.py +0 -0
  142. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameV2IPPowerSave.pyi +0 -0
  143. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameV2IPSetMaster.py +0 -0
  144. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameV2IPSetMaster.pyi +0 -0
  145. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameV2IPSourceSwitch.py +0 -0
  146. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameV2IPSourceSwitch.pyi +0 -0
  147. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameV2IPSources.py +0 -0
  148. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameV2IPSources.pyi +0 -0
  149. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameV2IPStats.py +0 -0
  150. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameV2IPStats.pyi +0 -0
  151. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameV2IPStreamDetails.py +0 -0
  152. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameV2IPStreamDetails.pyi +0 -0
  153. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameV2IPTiling.py +0 -0
  154. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameV2IPTiling.pyi +0 -0
  155. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameV2IPVideoWall.py +0 -0
  156. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameV2IPVideoWall.pyi +0 -0
  157. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameVolume.py +0 -0
  158. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameVolume.pyi +0 -0
  159. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameVolumeDown.py +0 -0
  160. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameVolumeDown.pyi +0 -0
  161. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameVolumeSet.py +0 -0
  162. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameVolumeSet.pyi +0 -0
  163. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameVolumeUp.py +0 -0
  164. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/FrameVolumeUp.pyi +0 -0
  165. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/LinkConfig.py +0 -0
  166. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/LinkConfig.pyi +0 -0
  167. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/Multiviewer.py +0 -0
  168. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/Multiviewer.pyi +0 -0
  169. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/PDUState.py +0 -0
  170. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/PDUState.pyi +0 -0
  171. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/Svd.py +0 -0
  172. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/Svd.pyi +0 -0
  173. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/V2IPConfig.py +0 -0
  174. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/V2IPConfig.pyi +0 -0
  175. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/V2IPStats.py +0 -0
  176. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/V2IPStats.pyi +0 -0
  177. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/__init__.py +0 -0
  178. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/__init__.pyi +0 -0
  179. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/proto/svd.csv +0 -0
  180. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/py.typed +0 -0
  181. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/remote/Bay.py +0 -0
  182. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/remote/Bay.pyi +0 -0
  183. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/remote/ConnectionAsync.py +0 -0
  184. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/remote/ConnectionAsync.pyi +0 -0
  185. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/remote/Device.py +0 -0
  186. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/remote/Device.pyi +0 -0
  187. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/remote/Link.py +0 -0
  188. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/remote/Link.pyi +0 -0
  189. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/remote/P8PDU.py +0 -0
  190. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/remote/P8PDU.pyi +0 -0
  191. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/remote/State.py +0 -0
  192. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/remote/State.pyi +0 -0
  193. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/remote/V2IP.py +0 -0
  194. {mx_remote-5.1.0 → mx_remote-5.2.0}/mx_remote/remote/V2IP.pyi +0 -0
  195. {mx_remote-5.1.0 → mx_remote-5.2.0}/pyproject.toml +0 -0
  196. {mx_remote-5.1.0 → mx_remote-5.2.0}/tests/audio_ir.py +0 -0
  197. {mx_remote-5.1.0 → mx_remote-5.2.0}/tests/capture.py +0 -0
  198. {mx_remote-5.1.0 → mx_remote-5.2.0}/tests/e2e.py +0 -0
  199. {mx_remote-5.1.0 → mx_remote-5.2.0}/tests/handlers.py +0 -0
  200. {mx_remote-5.1.0 → mx_remote-5.2.0}/tests/merge.py +0 -0
  201. {mx_remote-5.1.0 → mx_remote-5.2.0}/tests/newops.py +0 -0
  202. {mx_remote-5.1.0 → mx_remote-5.2.0}/tests/offsets.py +0 -0
  203. {mx_remote-5.1.0 → mx_remote-5.2.0}/tests/protogate.py +0 -0
  204. {mx_remote-5.1.0 → mx_remote-5.2.0}/tests/rcsettings.py +0 -0
  205. {mx_remote-5.1.0 → mx_remote-5.2.0}/tests/smoke.py +0 -0
  206. {mx_remote-5.1.0 → mx_remote-5.2.0}/tests/sources.py +0 -0
  207. {mx_remote-5.1.0 → mx_remote-5.2.0}/tests/stats.py +0 -0
  208. {mx_remote-5.1.0 → mx_remote-5.2.0}/tests/structs.py +0 -0
  209. {mx_remote-5.1.0 → mx_remote-5.2.0}/tests/txresult.py +0 -0
  210. {mx_remote-5.1.0 → mx_remote-5.2.0}/tests/unknowns.py +0 -0
  211. {mx_remote-5.1.0 → mx_remote-5.2.0}/tests/vdetails.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mx_remote
3
- Version: 5.1.0
3
+ Version: 5.2.0
4
4
  Summary: Python 3 library for interfacing with MX Remote compatible devices
5
5
  Project-URL: Homepage, https://github.com/opdenkamp/mx-remote/
6
6
  Project-URL: Documentation, https://github.com/opdenkamp/mx-remote/
@@ -14,7 +14,7 @@ Default communication modes:
14
14
  """
15
15
  import os
16
16
 
17
- VERSION = '5.1.0'
17
+ VERSION = '5.2.0'
18
18
  __version__ = VERSION
19
19
 
20
20
  MX_BCAST_UDP_PORT = 8811
@@ -26,6 +26,17 @@ MX_MCAST_UDP_IP = '224.8.8.8'
26
26
  MX_MCAST_UDP_PORT = 8812
27
27
  """UDP port used in multicast mode (default)."""
28
28
 
29
+ MXR_HELLO_INTERVAL_MIN = 2.5
30
+ """Shortest gap between hello announcements, in seconds (firmware ``hello_timeout`` minimum)."""
31
+
32
+ MXR_HELLO_INTERVAL_RAND = 2.5
33
+ """Random spread added to each hello interval, in seconds.
34
+
35
+ Mirrors ``rand_timeout_init(&mxr_class->hello_timeout, 2500, 2500)``: the delay is
36
+ re-drawn after every hello that is sent, so devices announcing on the same mesh do
37
+ not converge into synchronised bursts.
38
+ """
39
+
29
40
  V2IP_UDP_PORT_VIDEO = 50020
30
41
  """Default destination UDP port for a V2IP video stream (firmware ``V2IP_UDP_PORT_VIDEO``)."""
31
42
 
@@ -5,6 +5,8 @@ __version__ = VERSION
5
5
  MX_BCAST_UDP_PORT: int
6
6
  MX_MCAST_UDP_IP: str
7
7
  MX_MCAST_UDP_PORT: int
8
+ MXR_HELLO_INTERVAL_MIN: float
9
+ MXR_HELLO_INTERVAL_RAND: float
8
10
  V2IP_UDP_PORT_VIDEO: int
9
11
  V2IP_UDP_PORT_ANC: int
10
12
  V2IP_UDP_PORT_AUDIO: int
@@ -651,6 +651,10 @@ class EdidProfile(IntEnum):
651
651
  return '4K HDR Dolby Atmos'
652
652
  if (self.value >= EdidProfile.SINK_1.value) and (self.value <= EdidProfile.SINK_32.value):
653
653
  return f'copy from sink #{self.value - EdidProfile.SINK_1.value + 1}'
654
+ if self.value == EdidProfile.UNKNOWN.value:
655
+ # decode_enum lands here for any value this build has no member for,
656
+ # so it is a profile we cannot name, not one somebody configured
657
+ return 'unknown'
654
658
  return f"custom #{self.value}"
655
659
 
656
660
  TEMPLATE_1080P_STEREO = 0
@@ -0,0 +1,53 @@
1
+ ######################################################
2
+ ## MX Remote Python Interface ##
3
+ ## ##
4
+ ## author: Lars Op den Kamp (lars@opdenkamp-it.nl) ##
5
+ ## copyright (c) 2021-2026 Op den Kamp IT Solutions ##
6
+ ######################################################
7
+ '''Protocol frame for EDID profile change commands.'''
8
+
9
+ from .FrameBase import FrameBase
10
+ from ..Interface import DeviceRegistry, DeviceBase, EdidProfile, MxrDeviceUid
11
+ from .Constants import decode_enum
12
+
13
+ class FrameEDIDProfile(FrameBase):
14
+ '''Change an EDID profile on a target device.'''
15
+ @staticmethod
16
+ def construct(mxr:DeviceRegistry, target:DeviceBase, profile:EdidProfile) -> FrameBase|None:
17
+ '''Build an EDID profile change frame for transmission.'''
18
+ payload = target.remote_id.byte_value + \
19
+ bytes([(profile.value >> 0) & 0xFF, (profile.value >> 8) & 0xFF]) + \
20
+ bytes([0 for _ in range(6)])
21
+ return FrameBase.construct_base(target=target, mxr=mxr, opcode=0x34, payload=payload)
22
+
23
+ @property
24
+ def target_uid(self) -> MxrDeviceUid|None:
25
+ '''Device whose EDID profile is being set.'''
26
+ return self.payload_uuid(0)
27
+
28
+ @property
29
+ def target_device(self) -> DeviceBase|None:
30
+ return self.mxr.get_by_uid(self.target_uid)
31
+
32
+ @property
33
+ def profile_raw(self) -> int|None:
34
+ '''Profile as carried on the wire, before mapping to a local one.'''
35
+ return self.payload_u16(16)
36
+
37
+ @property
38
+ def profile(self) -> EdidProfile|None:
39
+ '''Requested EDID profile.
40
+
41
+ A value this build has no member for reads as EdidProfile.UNKNOWN rather
42
+ than raising or being folded into a real profile. Read profile_raw when
43
+ the distinction matters.
44
+ '''
45
+ return decode_enum(EdidProfile, self.profile_raw)
46
+
47
+ def __str__(self) -> str:
48
+ # The frame names no bay because it never needs to: the receiver applies it
49
+ # to MBAY_ID_0, its own input bay, so a device is the whole address.
50
+ target = self.target_device if (self.target_device is not None) else self.target_uid
51
+ profile = f"{self.profile} ({self.profile_raw})" \
52
+ if (self.profile == EdidProfile.UNKNOWN) else str(self.profile)
53
+ return f"set edid profile: {target} input 0 -> {profile}"
@@ -0,0 +1,15 @@
1
+ from ..Interface import DeviceBase as DeviceBase, DeviceRegistry as DeviceRegistry, EdidProfile as EdidProfile, MxrDeviceUid as MxrDeviceUid
2
+ from .Constants import decode_enum as decode_enum
3
+ from .FrameBase import FrameBase as FrameBase
4
+
5
+ class FrameEDIDProfile(FrameBase):
6
+ @staticmethod
7
+ def construct(mxr: DeviceRegistry, target: DeviceBase, profile: EdidProfile) -> FrameBase | None: ...
8
+ @property
9
+ def target_uid(self) -> MxrDeviceUid | None: ...
10
+ @property
11
+ def target_device(self) -> DeviceBase | None: ...
12
+ @property
13
+ def profile_raw(self) -> int | None: ...
14
+ @property
15
+ def profile(self) -> EdidProfile | None: ...
@@ -0,0 +1,62 @@
1
+ ######################################################
2
+ ## MX Remote Python Interface ##
3
+ ## ##
4
+ ## author: Lars Op den Kamp (lars@opdenkamp-it.nl) ##
5
+ ## copyright (c) 2021-2026 Op den Kamp IT Solutions ##
6
+ ######################################################
7
+ '''Protocol frame for changing a bay name on a device.'''
8
+
9
+ from .Constants import MXR_DEVICE_NAME_LEN
10
+ from .FrameBase import FrameBase
11
+ from ..Interface import DeviceRegistry, BayBase, DeviceBase, MxrDeviceUid
12
+
13
+ class FrameSetName(FrameBase):
14
+ '''Change a bay name.'''
15
+ @staticmethod
16
+ def construct(mxr:DeviceRegistry, target:BayBase, name:str) -> FrameBase|None:
17
+ '''Build a bay name change frame for transmission.'''
18
+ # 15, not 16: the receiver copies MXR_DEVICE_NAME_LEN - 1 bytes and
19
+ # terminates, so a name filling the field is stored one short. Sending
20
+ # all 16 means the bay ends up named something other than what was
21
+ # asked for, with success reported either way.
22
+ name = name[:MXR_DEVICE_NAME_LEN - 1]
23
+ name_bytes = name.encode(encoding='ascii', errors='replace').ljust(MXR_DEVICE_NAME_LEN, b'\x00')
24
+ payload = target.device.remote_id.byte_value + \
25
+ bytes([(target.port >> 0) & 0xFF, (target.port >> 8) & 0xFF]) + \
26
+ name_bytes
27
+ return FrameBase.construct_base(target=target, mxr=mxr, opcode=0x22, protocol=0x11, payload=payload)
28
+
29
+ @property
30
+ def target_uid(self) -> MxrDeviceUid|None:
31
+ '''Device whose bay is being renamed.'''
32
+ return self.payload_uuid(0)
33
+
34
+ @property
35
+ def target_device(self) -> DeviceBase|None:
36
+ return self.mxr.get_by_uid(self.target_uid)
37
+
38
+ @property
39
+ def port(self) -> int|None:
40
+ '''Port id of the bay being renamed (mbay_port_id, a u16).'''
41
+ return self.payload_u16(16)
42
+
43
+ @property
44
+ def bay(self) -> BayBase|None:
45
+ return self.payload_bay(device=self.target_device, idx=16, u16=True)
46
+
47
+ @property
48
+ def name(self) -> str|None:
49
+ '''The new name, read from its fixed 16-byte field.
50
+
51
+ The field carries no terminator of its own, so a name that fills it has
52
+ none and must not be read as a C string: read it at its width. A sender
53
+ that fills all MXR_DEVICE_NAME_LEN bytes is readable here, even though a
54
+ receiver keeps only the first MXR_DEVICE_NAME_LEN - 1 of them.
55
+ '''
56
+ return self.payload_str(18, 16)
57
+
58
+ def __str__(self) -> str:
59
+ target = self.bay if (self.bay is not None) else self.target_device
60
+ if (target is None):
61
+ return f"set bay name: port {self.port} -> '{self.name}'"
62
+ return f"set bay name: {target} -> '{self.name}'"
@@ -0,0 +1,17 @@
1
+ from ..Interface import BayBase as BayBase, DeviceBase as DeviceBase, DeviceRegistry as DeviceRegistry, MxrDeviceUid as MxrDeviceUid
2
+ from .Constants import MXR_DEVICE_NAME_LEN as MXR_DEVICE_NAME_LEN
3
+ from .FrameBase import FrameBase as FrameBase
4
+
5
+ class FrameSetName(FrameBase):
6
+ @staticmethod
7
+ def construct(mxr: DeviceRegistry, target: BayBase, name: str) -> FrameBase | None: ...
8
+ @property
9
+ def target_uid(self) -> MxrDeviceUid | None: ...
10
+ @property
11
+ def target_device(self) -> DeviceBase | None: ...
12
+ @property
13
+ def port(self) -> int | None: ...
14
+ @property
15
+ def bay(self) -> BayBase | None: ...
16
+ @property
17
+ def name(self) -> str | None: ...
@@ -14,10 +14,11 @@ import aiofiles
14
14
  import aiohttp
15
15
  import os
16
16
  from pathlib import Path
17
+ import random
17
18
  import time
18
19
 
19
20
  from .ConnectionAsync import ConnectionAsync
20
- from ..const import __version__
21
+ from ..const import MXR_HELLO_INTERVAL_MIN, MXR_HELLO_INTERVAL_RAND, __version__
21
22
  from .Device import Device
22
23
  from ..Interface import ConnectionCallbacks, DeviceRegistry, MxrDeviceUid, BayLinks, BayBase, DeviceBase, MxrCallbacks, AudioEndpoint, mxr_broadcast_address
23
24
  from ..proto.Constants import MXR_PROTOCOL_VERSION
@@ -60,7 +61,8 @@ class Remote(DeviceRegistry, ConnectionCallbacks):
60
61
  self._callbacks = State(callbacks, http_session)
61
62
  self.remotes:dict[MxrDeviceUid,Device] = {}
62
63
  self._links = BayLinks(self)
63
- self._last_hello = 0
64
+ self._hello_due = 0.0
65
+ self._probe_failures = 0
64
66
  self._tasks:set[asyncio.Task[None]] = set()
65
67
  self._uid:bytes|None = None
66
68
  self._uid_path = uid_path
@@ -218,19 +220,56 @@ class Remote(DeviceRegistry, ConnectionCallbacks):
218
220
  return True
219
221
  return False
220
222
 
223
+ def _on_task_done(self, task:'asyncio.Task[None]') -> None:
224
+ '''Drop a finished background task, and report it if something killed it.
225
+
226
+ asyncio only surfaces an unretrieved task exception when the task object is
227
+ garbage collected, which is a different moment and may never come, so a task
228
+ that dies otherwise does so in silence. Retrieving it here is what makes any
229
+ future background task fail loudly, including one whose own guard misses.
230
+ '''
231
+ self._tasks.discard(task)
232
+ if task.cancelled():
233
+ return
234
+ exc = task.exception()
235
+ if (exc is not None):
236
+ _LOGGER.error(f"background task {task.get_name()} died: {exc!r}")
237
+
221
238
  async def _background_probe(self) -> None:
222
239
  while not self._closing:
223
240
  await asyncio.sleep(1)
224
- tx_discover = False
225
- if (not self.has_completed_devices()):
226
- tx_discover = True
227
- else:
228
- for _, device in self.remotes.items():
229
- device.check_online()
230
- if not device.check_configuration_complete_timeout():
231
- tx_discover = True
232
- if tx_discover and ((time.time() - self._discover_timeout) >= 5):
233
- self.tx_discover()
241
+ try:
242
+ self._probe_once()
243
+ self._probe_failures = 0
244
+ except Exception as e:
245
+ # A send raises on a closed or unreachable socket. Letting that
246
+ # escape ends the task, and this loop is the only thing that
247
+ # announces this client and drives discovery, so one transient
248
+ # failure would silence it permanently.
249
+ #
250
+ # A disconnected interface fails every tick, so only the first
251
+ # carries a traceback: a full one per second buries everything
252
+ # else in the log, and the repeats say nothing the first did not.
253
+ self._probe_failures += 1
254
+ if (self._probe_failures == 1):
255
+ _LOGGER.warning(f"probe failed: {traceback.format_exc()}")
256
+ else:
257
+ _LOGGER.debug(f"probe still failing ({self._probe_failures}): {e!r}")
258
+
259
+ def _probe_once(self) -> None:
260
+ '''One pass of the probe loop: announce if due, then discover if needed.'''
261
+ if (time.time() >= self._hello_due):
262
+ self.tx_hello()
263
+ tx_discover = False
264
+ if (not self.has_completed_devices()):
265
+ tx_discover = True
266
+ else:
267
+ for _, device in self.remotes.items():
268
+ device.check_online()
269
+ if not device.check_configuration_complete_timeout():
270
+ tx_discover = True
271
+ if tx_discover and ((time.time() - self._discover_timeout) >= 5):
272
+ self.tx_discover()
234
273
 
235
274
  async def start_async(self) -> None:
236
275
  '''Start the server that listens for mx_remote frames from other devices.'''
@@ -240,7 +279,7 @@ class Remote(DeviceRegistry, ConnectionCallbacks):
240
279
  await self.conn.start_srv()
241
280
  checker = asyncio.create_task(self._background_probe())
242
281
  self._tasks.add(checker)
243
- checker.add_done_callback(self._tasks.discard)
282
+ checker.add_done_callback(self._on_task_done)
244
283
 
245
284
  async def stop_async(self) -> None:
246
285
  '''Stop the server and close all connections.'''
@@ -335,14 +374,32 @@ class Remote(DeviceRegistry, ConnectionCallbacks):
335
374
  _LOGGER.debug("discovering devices")
336
375
  return self.transmit(pkt.frame)
337
376
 
377
+ def _arm_hello(self) -> None:
378
+ '''Schedule the next announcement, mirroring the firmware's hello timer.
379
+
380
+ MatrixOS re-draws min 2.5s + random(0..2.5s) after each hello it sends,
381
+ so a mesh of devices does not synchronise into bursts. The interval is
382
+ re-drawn per send rather than fixed for the same reason.
383
+ '''
384
+ self._hello_due = time.time() + MXR_HELLO_INTERVAL_MIN \
385
+ + (random.random() * MXR_HELLO_INTERVAL_RAND)
386
+
338
387
  def tx_hello(self) -> int:
339
388
  '''Transmit a hello frame to announce this device on the network.'''
340
389
  pkt = constructFrameHello(self)
341
390
  if (pkt is None):
342
391
  return 0
343
392
  _LOGGER.debug("sending hello")
344
- self._last_hello = time.time()
345
- return self.transmit(pkt.frame)
393
+ sent = self.transmit(pkt.frame)
394
+ if (sent > 0):
395
+ # Only a hello that went out buys the next interval. A send that wrote
396
+ # nothing leaves _hello_due where it was, so the next probe tick tries
397
+ # again, which is what firmware does - it resets hello_timeout only in
398
+ # the success branch of mxr_transmit. Arming here on failure instead
399
+ # would silence a client for a full interval every time a write failed,
400
+ # and on an interface that never writes, permanently.
401
+ self._arm_hello()
402
+ return sent
346
403
 
347
404
  def on_connection_made(self) -> None:
348
405
  '''Callback invoked after ConnectionAsync has started the server.'''
@@ -374,10 +431,6 @@ class Remote(DeviceRegistry, ConnectionCallbacks):
374
431
  timestamp = time.time()
375
432
  self.process_frame(timestamp=timestamp, data=data, addr=addr)
376
433
 
377
- if (self.conn is not None):
378
- if (timestamp - self._last_hello >= 30):
379
- self.tx_hello()
380
-
381
434
  @override
382
435
  def on_mxr_update(self, data:Any) -> None:
383
436
  if isinstance(data, FrameHello):
@@ -1,7 +1,7 @@
1
1
  import aiohttp
2
2
  from ..Interface import AudioEndpoint as AudioEndpoint, BayBase as BayBase, BayLinks as BayLinks, ConnectionCallbacks as ConnectionCallbacks, DeviceBase as DeviceBase, DeviceRegistry as DeviceRegistry, MxrCallbacks as MxrCallbacks, mxr_broadcast_address as mxr_broadcast_address
3
3
  from ..Uid import MxrDeviceUid as MxrDeviceUid
4
- from ..const import MX_BCAST_UDP_PORT as MX_BCAST_UDP_PORT, MX_MCAST_UDP_IP as MX_MCAST_UDP_IP, MX_MCAST_UDP_PORT as MX_MCAST_UDP_PORT, __version__ as __version__
4
+ from ..const import MXR_HELLO_INTERVAL_MIN as MXR_HELLO_INTERVAL_MIN, MXR_HELLO_INTERVAL_RAND as MXR_HELLO_INTERVAL_RAND, MX_BCAST_UDP_PORT as MX_BCAST_UDP_PORT, MX_MCAST_UDP_IP as MX_MCAST_UDP_IP, MX_MCAST_UDP_PORT as MX_MCAST_UDP_PORT, __version__ as __version__
5
5
  from ..proto.Constants import MXR_PROTOCOL_VERSION as MXR_PROTOCOL_VERSION
6
6
  from ..proto.Factory import process_mxr_frame as process_mxr_frame
7
7
  from ..proto.FrameDiscover import constructFrameDiscover as constructFrameDiscover
@@ -0,0 +1,59 @@
1
+ """Enter where the runtime enters.
2
+
3
+ on_datagram_received is what ConnectionAsync calls. process_frame is one level
4
+ below it and is what a test reaches for, because it takes the arguments a test
5
+ already has - so a layer that only the runtime touches goes uncovered while the
6
+ suite looks like it exercises the receive path.
7
+
8
+ Here that layer re-announces this client's hello. It has one other caller, at
9
+ startup, so if it stopped working the client would announce once and then go
10
+ silent to every peer that started later.
11
+ """
12
+ import os
13
+ import struct
14
+ import sys
15
+ import time
16
+
17
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
18
+ import logging
19
+ logging.disable(logging.CRITICAL)
20
+
21
+ import mx_remote
22
+ from mx_remote.Uid import MxrDeviceUid
23
+ from mx_remote.proto.Factory import create_mxr_frame
24
+
25
+ UID = bytes(range(1, 17))
26
+ ADDR = ('192.0.2.9', 8812)
27
+
28
+ class RecordingConn:
29
+ """Stands in for ConnectionAsync so transmits can be counted."""
30
+ def __init__(self):
31
+ self.sent = []
32
+ def transmit(self, data):
33
+ self.sent.append(data)
34
+ return len(data)
35
+
36
+ def nm(s, sz=16):
37
+ b = s.encode('ascii')[:sz]
38
+ return b + bytes(sz - len(b))
39
+
40
+ HELLO = (struct.pack('<H', 0x28) + nm('MX-1') + nm('P8SN12345678') + nm('4.7.9')
41
+ + struct.pack('<I', 1 << 5))
42
+
43
+ mx = mx_remote.Remote(open_connection=False)
44
+ mx._uid = bytes(range(100, 116))
45
+ conn = RecordingConn()
46
+ mx.conn = conn
47
+
48
+ # a datagram arriving must be decoded, not merely counted
49
+ mx.on_datagram_received(create_mxr_frame(UID, 0x00, HELLO), ADDR)
50
+ assert mx.get_by_uid(MxrDeviceUid(UID)) is not None, 'the datagram was not processed'
51
+ print('entry : a datagram reaching on_datagram_received is decoded')
52
+
53
+ # Hello timing is not asserted here. It belongs to whatever drives the
54
+ # announcement, and pinning it to a datagram arriving would encode the trigger
55
+ # rather than the behaviour - an assertion that no datagram sends a hello
56
+ # passes trivially once nothing does.
57
+
58
+ print()
59
+ print('ALL OK')
@@ -0,0 +1,197 @@
1
+ ######################################################
2
+ ## MX Remote Python Interface ##
3
+ ## ##
4
+ ## author: Lars Op den Kamp (lars@opdenkamp-it.nl) ##
5
+ ## copyright (c) 2021-2026 Op den Kamp IT Solutions ##
6
+ ######################################################
7
+ '''A client announces itself on a clock, not when something arrives.
8
+
9
+ MatrixOS sends SYS_HELLO from its probe loop every 2.5s + random(0..2.5s),
10
+ re-drawn after each send and independent of received traffic. A client that
11
+ announces only on receipt is invisible to a silent mesh: nothing arrives, so it
12
+ never speaks, so nothing learns it is there.
13
+
14
+ This drives the real periodic loop rather than the pieces under it. Asserting
15
+ that "is it due" works and that tx_hello sends proves nothing about whether
16
+ anything ever asks.
17
+ '''
18
+
19
+ import asyncio, os, sys, time, logging
20
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
21
+ logging.disable(logging.CRITICAL)
22
+ import mx_remote
23
+ from mx_remote.proto.Factory import create_mxr_frame
24
+ from mx_remote.remote import Remote as RemoteModule
25
+
26
+ # keep the wall-clock cost down without touching the loop being tested
27
+ RemoteModule.MXR_HELLO_INTERVAL_MIN = 0.1
28
+ RemoteModule.MXR_HELLO_INTERVAL_RAND = 0.1
29
+
30
+ HELLO = 0x00
31
+
32
+ def opcode(frame):
33
+ return int.from_bytes(frame[20:22], 'little')
34
+
35
+ async def run(seconds, wire_ok=True):
36
+ mx = mx_remote.Remote(open_connection=False)
37
+ mx._uid = bytes(range(0x20, 0x30))
38
+ sent = []
39
+ mx.transmit = lambda data: (sent.append(data), len(data) if wire_ok else 0)[1]
40
+ task = asyncio.ensure_future(mx._background_probe())
41
+ await asyncio.sleep(seconds)
42
+ mx._closing = True
43
+ task.cancel()
44
+ try:
45
+ await task
46
+ except asyncio.CancelledError:
47
+ pass
48
+ return mx, [f for f in sent if opcode(f) == HELLO]
49
+
50
+ # ---- a silent network: nothing is fed in, and it must still announce, repeatedly
51
+ mx, hellos = asyncio.run(run(3.5))
52
+ print('silent network, 3.5s: %d hellos' % len(hellos))
53
+ assert len(hellos) >= 2, 'a client must keep announcing with no traffic arriving'
54
+
55
+ # ---- arriving datagrams must not announce, asserted by behaviour rather than by
56
+ # name. Checking that "tx_hello" is absent from on_datagram_received passes the
57
+ # moment the method is renamed, while the receipt-driven path is back in place.
58
+ def announces_on_receipt():
59
+ mx = mx_remote.Remote(open_connection=False)
60
+ mx._uid = bytes(range(0x20, 0x30))
61
+ mx._hello_due = time.time() + 3600 # nothing is due on the clock
62
+ sent = []
63
+ mx.transmit = lambda data: (sent.append(data), len(data))[1]
64
+ peer = bytes(range(1, 17))
65
+ for _ in range(20):
66
+ mx.on_datagram_received(create_mxr_frame(peer, 0x01, b''), ('192.0.2.9', 8812))
67
+ return [f for f in sent if opcode(f) == HELLO]
68
+
69
+ on_receipt = announces_on_receipt()
70
+ print('20 datagrams, nothing due: %d hellos' % len(on_receipt))
71
+ assert len(on_receipt) == 0, 'arriving traffic must not trigger an announcement'
72
+
73
+ # ---- a hello that did not go out does not buy the next interval, as in firmware
74
+ mx, hellos = asyncio.run(run(2.5, wire_ok=False))
75
+ print('failing wire, 2.5s : %d attempts' % len(hellos))
76
+ assert len(hellos) >= 2, 'a send that wrote nothing must be retried, not treated as done'
77
+
78
+ # ---- the interval is re-drawn per send rather than fixed
79
+ mx2 = mx_remote.Remote(open_connection=False)
80
+ mx2._uid = bytes(range(0x20, 0x30))
81
+ mx2.transmit = lambda data: len(data)
82
+ # Measure the interval, not the due timestamp: _hello_due moves every call because
83
+ # the clock advances, so counting distinct timestamps says nothing about whether
84
+ # anything was drawn.
85
+ deltas = []
86
+ for _ in range(40):
87
+ before = time.time()
88
+ mx2._arm_hello()
89
+ deltas.append(mx2._hello_due - before)
90
+ spread = max(deltas) - min(deltas)
91
+ print('interval spread : %.4fs over 40 draws (rand range %.2fs)'
92
+ % (spread, RemoteModule.MXR_HELLO_INTERVAL_RAND))
93
+ assert spread > RemoteModule.MXR_HELLO_INTERVAL_RAND * 0.3, \
94
+ 'the interval must be re-drawn per send, not fixed'
95
+ assert min(deltas) >= RemoteModule.MXR_HELLO_INTERVAL_MIN, 'a draw must never be shorter than the minimum'
96
+
97
+ # ---- a send that raises must not end the loop. This loop is now the only thing
98
+ # that announces and the only thing that drives discovery, so a task that dies on
99
+ # a transient socket error silences the client for good.
100
+ async def survives_raising_transmit():
101
+ mx = mx_remote.Remote(open_connection=False)
102
+ mx._uid = bytes(range(0x20, 0x30))
103
+ sent = []
104
+ state = {'raise': True}
105
+ def wire(data):
106
+ if state['raise']:
107
+ raise OSError('network is unreachable')
108
+ sent.append(data)
109
+ return len(data)
110
+ mx.transmit = wire
111
+ task = asyncio.ensure_future(mx._background_probe())
112
+ await asyncio.sleep(2.2)
113
+ alive_while_failing = not task.done()
114
+ state['raise'] = False # the socket comes back
115
+ await asyncio.sleep(2.2)
116
+ mx._closing = True
117
+ task.cancel()
118
+ try:
119
+ await task
120
+ except asyncio.CancelledError:
121
+ pass
122
+ return alive_while_failing, [f for f in sent if opcode(f) == HELLO]
123
+
124
+ alive, recovered = asyncio.run(survives_raising_transmit())
125
+ print('raising wire : loop alive=%s, hellos after recovery=%d' % (alive, len(recovered)))
126
+ assert alive, 'a raising send must not end the probe loop'
127
+ assert len(recovered) >= 1, 'the client must announce again once sending works'
128
+
129
+ # ---- a task that dies must say so. asyncio only reports an unretrieved task
130
+ # exception when the task object is collected, which is a different moment and may
131
+ # never come, so without this a background task dies in silence. Driven through
132
+ # start_async so the real registration is what is under test, not _on_task_done.
133
+ class Boom(BaseException):
134
+ """Not an Exception, so the loop's own guard does not catch it."""
135
+
136
+ class FakeConn:
137
+ async def start_srv(self):
138
+ return (None, None)
139
+ def close(self):
140
+ pass
141
+
142
+ async def dying_task_is_reported():
143
+ records = []
144
+ class Catch(logging.Handler):
145
+ def emit(self, rec):
146
+ records.append(rec)
147
+ logging.disable(logging.NOTSET)
148
+ log = logging.getLogger('mx_remote.remote.Remote')
149
+ log.addHandler(Catch()); log.setLevel(logging.DEBUG)
150
+ try:
151
+ mx = mx_remote.Remote(open_connection=False)
152
+ mx._uid = bytes(range(0x20, 0x30))
153
+ mx.conn = FakeConn()
154
+ mx._probe_once = lambda: (_ for _ in ()).throw(Boom('probe exploded'))
155
+ await mx.start_async()
156
+ await asyncio.sleep(1.6)
157
+ task = next(iter(mx._tasks), None)
158
+ died = [r for r in records if r.levelno >= logging.ERROR and 'died' in r.getMessage()]
159
+ return died
160
+ finally:
161
+ log.removeHandler(Catch)
162
+ logging.disable(logging.CRITICAL)
163
+
164
+ died = asyncio.run(dying_task_is_reported())
165
+ print('task killed by a BaseException: %d error(s) logged' % len(died))
166
+ assert len(died) >= 1, 'a background task that dies must be reported, not vanish'
167
+
168
+ # ---- a disconnected interface must not bury the log in identical tracebacks
169
+ async def one_traceback_then_quiet():
170
+ records = []
171
+ class Catch(logging.Handler):
172
+ def emit(self, rec):
173
+ records.append(rec)
174
+ logging.disable(logging.NOTSET)
175
+ log = logging.getLogger('mx_remote.remote.Remote')
176
+ log.addHandler(Catch()); log.setLevel(logging.DEBUG)
177
+ try:
178
+ mx = mx_remote.Remote(open_connection=False)
179
+ mx._uid = bytes(range(0x20, 0x30))
180
+ mx.transmit = lambda data: (_ for _ in ()).throw(OSError('network is unreachable'))
181
+ task = asyncio.ensure_future(mx._background_probe())
182
+ await asyncio.sleep(3.2)
183
+ mx._closing = True
184
+ task.cancel()
185
+ try:
186
+ await task
187
+ except asyncio.CancelledError:
188
+ pass
189
+ return [r for r in records if r.levelno >= logging.WARNING]
190
+ finally:
191
+ logging.disable(logging.CRITICAL)
192
+
193
+ warns = asyncio.run(one_traceback_then_quiet())
194
+ print('3 failing ticks : %d warning(s)' % len(warns))
195
+ assert len(warns) == 1, 'only the first failure carries a traceback; repeats must not flood'
196
+
197
+ print('ALL OK')