mx-remote 5.6.0__tar.gz → 5.8.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 (224) hide show
  1. {mx_remote-5.6.0 → mx_remote-5.8.0}/AGENTS.md +15 -0
  2. {mx_remote-5.6.0 → mx_remote-5.8.0}/PKG-INFO +1 -1
  3. {mx_remote-5.6.0 → mx_remote-5.8.0}/docs/oneip.md +68 -0
  4. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/Interface.py +159 -1
  5. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/Interface.pyi +31 -1
  6. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/const.py +1 -1
  7. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/Constants.py +43 -0
  8. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/Constants.pyi +7 -0
  9. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameV2IPDeviceConfiguration.py +104 -4
  10. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameV2IPDeviceConfiguration.pyi +7 -2
  11. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/Svd.py +4 -0
  12. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/Svd.pyi +2 -0
  13. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/remote/Device.py +146 -1
  14. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/remote/Device.pyi +15 -2
  15. {mx_remote-5.6.0 → mx_remote-5.8.0}/tests/run.py +2 -0
  16. mx_remote-5.8.0/tests/v2ipscaling.py +161 -0
  17. mx_remote-5.8.0/tests/v2ipsubject.py +124 -0
  18. {mx_remote-5.6.0 → mx_remote-5.8.0}/.gitignore +0 -0
  19. {mx_remote-5.6.0 → mx_remote-5.8.0}/CLAUDE.md +0 -0
  20. {mx_remote-5.6.0 → mx_remote-5.8.0}/LICENSE +0 -0
  21. {mx_remote-5.6.0 → mx_remote-5.8.0}/README.md +0 -0
  22. {mx_remote-5.6.0 → mx_remote-5.8.0}/docs/README.md +0 -0
  23. {mx_remote-5.6.0 → mx_remote-5.8.0}/docs/_static/brand.css +0 -0
  24. {mx_remote-5.6.0 → mx_remote-5.8.0}/docs/_static/favicon.svg +0 -0
  25. {mx_remote-5.6.0 → mx_remote-5.8.0}/docs/_static/logo-dark.svg +0 -0
  26. {mx_remote-5.6.0 → mx_remote-5.8.0}/docs/_static/logo.svg +0 -0
  27. {mx_remote-5.6.0 → mx_remote-5.8.0}/docs/api.md +0 -0
  28. {mx_remote-5.6.0 → mx_remote-5.8.0}/docs/audio.md +0 -0
  29. {mx_remote-5.6.0 → mx_remote-5.8.0}/docs/callbacks.md +0 -0
  30. {mx_remote-5.6.0 → mx_remote-5.8.0}/docs/conf.py +0 -0
  31. {mx_remote-5.6.0 → mx_remote-5.8.0}/docs/configuration.md +0 -0
  32. {mx_remote-5.6.0 → mx_remote-5.8.0}/docs/devices-and-bays.md +0 -0
  33. {mx_remote-5.6.0 → mx_remote-5.8.0}/docs/diagnostics.md +0 -0
  34. {mx_remote-5.6.0 → mx_remote-5.8.0}/docs/index.md +0 -0
  35. {mx_remote-5.6.0 → mx_remote-5.8.0}/docs/multiviewer.md +0 -0
  36. {mx_remote-5.6.0 → mx_remote-5.8.0}/docs/requirements.txt +0 -0
  37. {mx_remote-5.6.0 → mx_remote-5.8.0}/docs/routing.md +0 -0
  38. {mx_remote-5.6.0 → mx_remote-5.8.0}/hatch_build.py +0 -0
  39. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/Uid.py +0 -0
  40. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/Uid.pyi +0 -0
  41. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/__init__.py +0 -0
  42. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/__init__.pyi +0 -0
  43. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/compat.py +0 -0
  44. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/compat.pyi +0 -0
  45. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/const.pyi +0 -0
  46. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/main.py +0 -0
  47. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/main.pyi +0 -0
  48. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/BayConfig.py +0 -0
  49. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/BayConfig.pyi +0 -0
  50. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/Data.py +0 -0
  51. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/Data.pyi +0 -0
  52. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/Factory.py +0 -0
  53. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/Factory.pyi +0 -0
  54. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameAmpDolbySettings.py +0 -0
  55. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameAmpDolbySettings.pyi +0 -0
  56. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameAmpZoneSettings.py +0 -0
  57. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameAmpZoneSettings.pyi +0 -0
  58. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameAudioClip.py +0 -0
  59. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameAudioClip.pyi +0 -0
  60. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameAudioSetRoute.py +0 -0
  61. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameAudioSetRoute.pyi +0 -0
  62. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameBase.py +0 -0
  63. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameBase.pyi +0 -0
  64. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameBayConfig.py +0 -0
  65. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameBayConfig.pyi +0 -0
  66. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameBayConfigSecondary.py +0 -0
  67. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameBayConfigSecondary.pyi +0 -0
  68. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameBayHide.py +0 -0
  69. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameBayHide.pyi +0 -0
  70. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameBayStatus.py +0 -0
  71. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameBayStatus.pyi +0 -0
  72. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameConnectStatus.py +0 -0
  73. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameConnectStatus.pyi +0 -0
  74. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameDebug.py +0 -0
  75. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameDebug.pyi +0 -0
  76. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameDiscover.py +0 -0
  77. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameDiscover.pyi +0 -0
  78. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameEDID.py +0 -0
  79. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameEDID.pyi +0 -0
  80. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameEDIDProfile.py +0 -0
  81. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameEDIDProfile.pyi +0 -0
  82. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameFactoryReset.py +0 -0
  83. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameFactoryReset.pyi +0 -0
  84. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameFilterStatus.py +0 -0
  85. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameFilterStatus.pyi +0 -0
  86. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameFirmwareVersion.py +0 -0
  87. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameFirmwareVersion.pyi +0 -0
  88. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameHeader.py +0 -0
  89. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameHeader.pyi +0 -0
  90. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameHello.py +0 -0
  91. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameHello.pyi +0 -0
  92. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameLinks.py +0 -0
  93. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameLinks.pyi +0 -0
  94. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameMeshOperation.py +0 -0
  95. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameMeshOperation.pyi +0 -0
  96. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameMirrorStatus.py +0 -0
  97. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameMirrorStatus.pyi +0 -0
  98. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameMonitoringPulse.py +0 -0
  99. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameMonitoringPulse.pyi +0 -0
  100. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameNetworkStatus.py +0 -0
  101. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameNetworkStatus.pyi +0 -0
  102. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FramePowerChange.py +0 -0
  103. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FramePowerChange.pyi +0 -0
  104. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameRCAction.py +0 -0
  105. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameRCAction.pyi +0 -0
  106. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameRCIr.py +0 -0
  107. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameRCIr.pyi +0 -0
  108. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameRCKey.py +0 -0
  109. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameRCKey.pyi +0 -0
  110. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameRCSettings.py +0 -0
  111. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameRCSettings.pyi +0 -0
  112. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameReboot.py +0 -0
  113. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameReboot.pyi +0 -0
  114. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameRoutingChange.py +0 -0
  115. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameRoutingChange.pyi +0 -0
  116. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameSetInstaller.py +0 -0
  117. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameSetInstaller.pyi +0 -0
  118. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameSetName.py +0 -0
  119. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameSetName.pyi +0 -0
  120. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameSetRoute.py +0 -0
  121. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameSetRoute.pyi +0 -0
  122. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameSetupStatus.py +0 -0
  123. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameSetupStatus.pyi +0 -0
  124. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameSignalStatus.py +0 -0
  125. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameSignalStatus.pyi +0 -0
  126. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameSignalStatusNew.py +0 -0
  127. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameSignalStatusNew.pyi +0 -0
  128. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameSysTemperature.py +0 -0
  129. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameSysTemperature.pyi +0 -0
  130. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameSystemStatus.py +0 -0
  131. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameSystemStatus.pyi +0 -0
  132. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameTXRCAction.py +0 -0
  133. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameTXRCAction.pyi +0 -0
  134. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameTXRCKey.py +0 -0
  135. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameTXRCKey.pyi +0 -0
  136. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameTopology.py +0 -0
  137. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameTopology.pyi +0 -0
  138. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameTxIR.py +0 -0
  139. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameTxIR.pyi +0 -0
  140. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameUpgradeFPGA.py +0 -0
  141. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameUpgradeFPGA.pyi +0 -0
  142. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameV2IPAudio.py +0 -0
  143. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameV2IPAudio.pyi +0 -0
  144. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameV2IPBayMapping.py +0 -0
  145. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameV2IPBayMapping.pyi +0 -0
  146. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameV2IPDetectBays.py +0 -0
  147. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameV2IPDetectBays.pyi +0 -0
  148. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameV2IPLink.py +0 -0
  149. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameV2IPLink.pyi +0 -0
  150. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameV2IPManualSourceSwitch.py +0 -0
  151. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameV2IPManualSourceSwitch.pyi +0 -0
  152. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameV2IPMultiviewer.py +0 -0
  153. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameV2IPMultiviewer.pyi +0 -0
  154. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameV2IPPowerSave.py +0 -0
  155. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameV2IPPowerSave.pyi +0 -0
  156. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameV2IPSetMaster.py +0 -0
  157. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameV2IPSetMaster.pyi +0 -0
  158. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameV2IPSourceSwitch.py +0 -0
  159. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameV2IPSourceSwitch.pyi +0 -0
  160. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameV2IPSources.py +0 -0
  161. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameV2IPSources.pyi +0 -0
  162. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameV2IPStats.py +0 -0
  163. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameV2IPStats.pyi +0 -0
  164. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameV2IPTiling.py +0 -0
  165. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameV2IPTiling.pyi +0 -0
  166. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameV2IPVideoWall.py +0 -0
  167. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameV2IPVideoWall.pyi +0 -0
  168. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameVolume.py +0 -0
  169. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameVolume.pyi +0 -0
  170. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameVolumeDown.py +0 -0
  171. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameVolumeDown.pyi +0 -0
  172. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameVolumeSet.py +0 -0
  173. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameVolumeSet.pyi +0 -0
  174. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameVolumeUp.py +0 -0
  175. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/FrameVolumeUp.pyi +0 -0
  176. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/LinkConfig.py +0 -0
  177. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/LinkConfig.pyi +0 -0
  178. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/Multiviewer.py +0 -0
  179. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/Multiviewer.pyi +0 -0
  180. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/V2IPConfig.py +0 -0
  181. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/V2IPConfig.pyi +0 -0
  182. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/V2IPStats.py +0 -0
  183. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/V2IPStats.pyi +0 -0
  184. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/__init__.py +0 -0
  185. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/__init__.pyi +0 -0
  186. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/proto/svd.csv +0 -0
  187. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/py.typed +0 -0
  188. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/remote/Bay.py +0 -0
  189. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/remote/Bay.pyi +0 -0
  190. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/remote/ConnectionAsync.py +0 -0
  191. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/remote/ConnectionAsync.pyi +0 -0
  192. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/remote/Link.py +0 -0
  193. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/remote/Link.pyi +0 -0
  194. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/remote/Remote.py +0 -0
  195. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/remote/Remote.pyi +0 -0
  196. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/remote/State.py +0 -0
  197. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/remote/State.pyi +0 -0
  198. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/remote/V2IP.py +0 -0
  199. {mx_remote-5.6.0 → mx_remote-5.8.0}/mx_remote/remote/V2IP.pyi +0 -0
  200. {mx_remote-5.6.0 → mx_remote-5.8.0}/pyproject.toml +0 -0
  201. {mx_remote-5.6.0 → mx_remote-5.8.0}/tests/audio_ir.py +0 -0
  202. {mx_remote-5.6.0 → mx_remote-5.8.0}/tests/capture.py +0 -0
  203. {mx_remote-5.6.0 → mx_remote-5.8.0}/tests/e2e.py +0 -0
  204. {mx_remote-5.6.0 → mx_remote-5.8.0}/tests/entry.py +0 -0
  205. {mx_remote-5.6.0 → mx_remote-5.8.0}/tests/handlers.py +0 -0
  206. {mx_remote-5.6.0 → mx_remote-5.8.0}/tests/handlers_cover.py +0 -0
  207. {mx_remote-5.6.0 → mx_remote-5.8.0}/tests/hello.py +0 -0
  208. {mx_remote-5.6.0 → mx_remote-5.8.0}/tests/idempotent.py +0 -0
  209. {mx_remote-5.6.0 → mx_remote-5.8.0}/tests/lengths.py +0 -0
  210. {mx_remote-5.6.0 → mx_remote-5.8.0}/tests/merge.py +0 -0
  211. {mx_remote-5.6.0 → mx_remote-5.8.0}/tests/newops.py +0 -0
  212. {mx_remote-5.6.0 → mx_remote-5.8.0}/tests/offsets.py +0 -0
  213. {mx_remote-5.6.0 → mx_remote-5.8.0}/tests/proto_stamp.py +0 -0
  214. {mx_remote-5.6.0 → mx_remote-5.8.0}/tests/protogate.py +0 -0
  215. {mx_remote-5.6.0 → mx_remote-5.8.0}/tests/rcsettings.py +0 -0
  216. {mx_remote-5.6.0 → mx_remote-5.8.0}/tests/roundtrip.py +0 -0
  217. {mx_remote-5.6.0 → mx_remote-5.8.0}/tests/smoke.py +0 -0
  218. {mx_remote-5.6.0 → mx_remote-5.8.0}/tests/sources.py +0 -0
  219. {mx_remote-5.6.0 → mx_remote-5.8.0}/tests/stats.py +0 -0
  220. {mx_remote-5.6.0 → mx_remote-5.8.0}/tests/structs.py +0 -0
  221. {mx_remote-5.6.0 → mx_remote-5.8.0}/tests/txresult.py +0 -0
  222. {mx_remote-5.6.0 → mx_remote-5.8.0}/tests/unknowns.py +0 -0
  223. {mx_remote-5.6.0 → mx_remote-5.8.0}/tests/vdetails.py +0 -0
  224. {mx_remote-5.6.0 → mx_remote-5.8.0}/tests/wirefix.py +0 -0
@@ -85,6 +85,21 @@ Version is single-sourced in `mx_remote/const.py` (`VERSION = '...'`); `pyprojec
85
85
  reads it via regex, so bump it there only. Release commits follow the convention
86
86
  `bump to X.Y.Z`.
87
87
 
88
+ ### Releases
89
+ `.github/workflows/release.yml` builds and uploads, so a release needs no local
90
+ build and no PyPI credentials:
91
+
92
+ ```sh
93
+ # after the `bump to X.Y.Z` commit is pushed
94
+ git tag -a vX.Y.Z -m 'mx-remote X.Y.Z' && git push origin vX.Y.Z
95
+ gh release create vX.Y.Z --title 'mx-remote X.Y.Z' --verify-tag --notes '...'
96
+ ```
97
+
98
+ Publishing the release runs the suites against the tag, builds the wheel and
99
+ sdist, uploads them to PyPI over Trusted Publishing, and attaches them to the
100
+ release page. Notes stay hand-written: a one-line summary, `##` sections, and a
101
+ closing `**Full Changelog**:` compare link.
102
+
88
103
  ### Build hook
89
104
  `hatch_build.py` generates `.pyi` type stubs via `mypy stubgen` at build time and
90
105
  force-includes them in the wheel (they are gitignored). `mx_remote` ships as typed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mx_remote
3
- Version: 5.6.0
3
+ Version: 5.8.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://opdenkamp.github.io/mx-remote/
@@ -14,6 +14,9 @@ if device.is_v2ip and device.v2ip_sources:
14
14
  print(f"Audio: {source.audio.ip}:{source.audio.port}")
15
15
 
16
16
  # stream details (encoder/decoder config)
17
+ # A configuration frame names its subject in the payload, so a controller's
18
+ # write for a transceiver reaches the transceiver's record - the controller's
19
+ # own stays empty, which is what the device always meant.
17
20
  if device.v2ip_details:
18
21
  details = device.v2ip_details
19
22
  print(f"Video: {details.video}")
@@ -91,6 +94,71 @@ reading from a repeat, and note it cannot see a `TX_BRIDGE_UNLOCKED` flag
91
94
  carried forward across a format change: a value held over is a stored reading
92
95
  like any other.
93
96
 
97
+ ## Sink output scaling
98
+
99
+ A sink scales for two independent reasons: automatic scaling, and a configured
100
+ output mode. Each is written behind its own validity bit, so moving one leaves
101
+ the other where it is.
102
+
103
+ ```python
104
+ # what the sink reports. Trust this block only from a device whose hello carries
105
+ # CONFIG_INITIALISED - firmware without it builds the block over uninitialised
106
+ # stack, where the valid bit itself is noise.
107
+ scaling = device.v2ip_details.scaling
108
+ print(scaling.auto_scaling) # None when the sender did not say
109
+ print(scaling.configured_mode) # (signal type, refresh), None when it has none
110
+
111
+ # a mode is given as a depth and a colour space, never as a packed signal-type
112
+ # word read back off the wire: a sink with no mode reports the word carrying the
113
+ # index for "no depth", which a receiver decodes to zero and drops in silence.
114
+ mode = mx_remote.V2IPOutputMode(svd=16, depth=12,
115
+ colour=mx_remote.VideoColourSpace.YUV422, refresh=60)
116
+
117
+ await device.set_v2ip_auto_scaling(False) # turn it off before setting a mode
118
+ await device.set_v2ip_output_mode(mode)
119
+ await device.set_v2ip_auto_scaling(True) # back on, if that is what you want
120
+
121
+ await device.clear_v2ip_output_mode() # the only way to say "no mode"
122
+ ```
123
+
124
+ **Set the mode with automatic scaling off.** A sink scaling automatically
125
+ refuses a mode whose format the attached display does not list, and refuses it
126
+ in silence. Setting the mode first and turning automatic scaling back on
127
+ afterwards is the order that survives, because the mode is checked while
128
+ automatic scaling is still off.
129
+
130
+ Nothing acknowledges any of these. `set_v2ip_output_mode` returns False for a
131
+ mode no sink would take, but a True only says the frame went out: the sink
132
+ weighs the format against the display's EDID and against what its own output
133
+ stage can produce, and refuses silently either way. Read `v2ip_details.scaling`
134
+ back on the device's next report to learn what it did.
135
+
136
+ A scaling change makes the device rebuild and rebroadcast its sink block, so
137
+ expect `device.v2ip_sink` to read empty for a moment afterwards - see
138
+ [what an empty sink block means](#what-an-empty-sink-block-means).
139
+
140
+ ## What an empty sink block means
141
+
142
+ `device.v2ip_sink` addresses that read as unset mean "no route, or the sink
143
+ could not work one out", never "definitely not subscribed". This is the one part
144
+ of a device configuration with no validity marker of its own, so a sender with
145
+ nothing to say sends zeros and every receiver stores them. A sender leaves it
146
+ empty whenever its own stream configuration does not resolve, which covers more
147
+ than having no route: a selected source whose record has not arrived yet, the
148
+ state after a restart at either end, missing audio bay configuration, or a
149
+ stream failing its validity check.
150
+
151
+ Expect that reading rather than guarding against it. Any scaling change rebuilds
152
+ and rebroadcasts the block, and a write aimed at a remote bay sends it zeroed
153
+ however it was requested, so an empty reading turns up most often during exactly
154
+ the no-signal troubleshooting that prompted the change. A device's periodic
155
+ report puts a real route back within a minute of it having one, so wait one out
156
+ rather than treat the first empty reading as an answer.
157
+
158
+ The library caches an empty reading rather than dropping it. A sink that has
159
+ genuinely lost its route sends the same zeros, and so does every report after
160
+ it, so refusing them would hold a route that nothing later could clear.
161
+
94
162
  ## Mesh and firmware
95
163
 
96
164
  ```python
@@ -28,7 +28,7 @@ from .proto.Multiviewer import (
28
28
  MultiviewerITCMode,
29
29
  MultiviewerHDCPMode,
30
30
  )
31
- from .proto.Svd import SvdMap
31
+ from .proto.Svd import SvdMap, lookup_svd
32
32
  from typing import Any, Callable
33
33
  from .Uid import MxrDeviceUid, MxrBayUid
34
34
 
@@ -1390,6 +1390,28 @@ class DeviceV2IPScalingSettings(ABC):
1390
1390
  is the only option bit with a defined meaning.
1391
1391
  '''
1392
1392
 
1393
+ @property
1394
+ def configured_mode(self) -> 'tuple[MxrSignalType, int]|None':
1395
+ '''The mode and refresh rate this sink scales to, None when it has none.
1396
+
1397
+ The two are distinct on the wire: a sink with no mode configured leaves
1398
+ MXR_SCALING_FLAG_MODE_VALID clear, and never sets it over a zero mode.
1399
+
1400
+ Trust it only from a peer whose hello carries CONFIG_INITIALISED
1401
+ (DeviceBase.config_initialised). Firmware without that builds this block
1402
+ over uninitialised stack, where the valid bit itself is noise.
1403
+ '''
1404
+ if ((self.flags & MXR_SCALING_FLAG_MODE_VALID) == 0):
1405
+ return None
1406
+ return (MxrSignalType(self.mode.to_bytes(2, 'little')), self.refresh)
1407
+
1408
+ @property
1409
+ def auto_scaling(self) -> bool|None:
1410
+ '''Whether the output scales automatically, None when the sender did not say.'''
1411
+ if ((self.flags & MXR_SCALING_FLAG_OPTIONS_VALID) == 0):
1412
+ return None
1413
+ return ((self.flags & MXR_SCALING_FLAG_AUTO_SCALING) != 0)
1414
+
1393
1415
  def v2ip_stream_valid(stream:'V2IPStreamSource|None') -> bool:
1394
1416
  """
1395
1417
  True when a stream source carries an address a frame actually meant to send.
@@ -1418,6 +1440,72 @@ def v2ip_stream_cleared(stream:'V2IPStreamSource|None') -> bool:
1418
1440
  """True when a stream source is zeroed, which is how "no source" is spelled."""
1419
1441
  return (stream is not None) and (stream.ip == '0.0.0.0') and (stream.port == 0)
1420
1442
 
1443
+ class V2IPOutputMode:
1444
+ """
1445
+ The output format to scale a V2IP sink to.
1446
+
1447
+ Built from a depth and a colour space rather than from a packed
1448
+ mxr_signal_type word, so the word a caller sends cannot carry the unset bpp
1449
+ index a sink reports while it has no mode configured.
1450
+ """
1451
+ def __init__(self, svd:int, depth:int, colour:VideoColourSpace, refresh:int) -> None:
1452
+ self.svd = svd
1453
+ """CTA-861 short video descriptor to output."""
1454
+ self.depth = depth
1455
+ """Bit depth: 8, 10 or 12."""
1456
+ self.colour = colour
1457
+ """Colour space to output."""
1458
+ self.refresh = refresh
1459
+ """Refresh rate in Hz, V2IP_SCALING_REFRESH_MIN to V2IP_SCALING_REFRESH_MAX."""
1460
+
1461
+ def validate(self) -> str|None:
1462
+ """
1463
+ Why a sink will refuse this mode, or None when it will take it.
1464
+
1465
+ Checked here because a sink checks it and then says nothing: every value
1466
+ this rejects is one the receiver decodes cleanly and drops, leaving a
1467
+ caller with a send that succeeded and a setting that did not move.
1468
+
1469
+ Passing is not a guarantee. A sink also weighs the format against the
1470
+ EDID of the display attached to it and against what its own clock and
1471
+ output stage can produce, and none of that is knowable from here.
1472
+ """
1473
+ if (self.svd == 0):
1474
+ return "svd 0 is how a mode is cleared, not a mode to set"
1475
+ if (lookup_svd(self.svd) is None):
1476
+ return "the svd names no known video descriptor"
1477
+ if (mxr_sig_bpp_index(self.depth) is None):
1478
+ return "a V2IP output stage takes 8, 10 or 12 bits per pixel"
1479
+ if (self.colour not in (VideoColourSpace.RGB, VideoColourSpace.YUV444,
1480
+ VideoColourSpace.YUV422, VideoColourSpace.YUV420)):
1481
+ return "the colour space names none of RGB, 4:4:4, 4:2:2 or 4:2:0"
1482
+ if not (V2IP_SCALING_REFRESH_MIN <= self.refresh <= V2IP_SCALING_REFRESH_MAX):
1483
+ return f"the refresh rate is outside {V2IP_SCALING_REFRESH_MIN}..{V2IP_SCALING_REFRESH_MAX}Hz"
1484
+ return None
1485
+
1486
+ @property
1487
+ def signal_type(self) -> MxrSignalType:
1488
+ """
1489
+ The packed signal type a scaling write carries for this mode.
1490
+
1491
+ Call validate() first: an unvalidated depth packs as the index for
1492
+ "no depth", which a receiver drops.
1493
+ """
1494
+ return MxrSignalType.from_parts(svd=self.svd, colour=int(self.colour),
1495
+ bpp_index=(mxr_sig_bpp_index(self.depth) or 0))
1496
+
1497
+ def __eq__(self, other:Any) -> bool:
1498
+ if not isinstance(other, V2IPOutputMode):
1499
+ return NotImplemented
1500
+ return (self.svd == other.svd) and (self.depth == other.depth) \
1501
+ and (self.colour == other.colour) and (self.refresh == other.refresh)
1502
+
1503
+ def __str__(self) -> str:
1504
+ return f"svd {self.svd}, colour {self.colour}, {self.depth}bpp, {self.refresh}Hz"
1505
+
1506
+ def __repr__(self) -> str:
1507
+ return str(self)
1508
+
1421
1509
  class V2IPScalingSettings(DeviceV2IPScalingSettings):
1422
1510
  """Plain holder for a merged mxr_scaling_config."""
1423
1511
  def __init__(self, mode:int, refresh:int, flags:int) -> None:
@@ -1627,6 +1715,28 @@ class DeviceV2IPSink:
1627
1715
  when present, each stream may still carry an all-zero ip when that stream is
1628
1716
  inactive. ``audio_fmt`` carries the resolved sink audio format (zero fields
1629
1717
  fall back to firmware defaults of 48kHz / 2 channels).
1718
+
1719
+ **An address that reads as unset means "no route, or the sink could not work
1720
+ one out", never "definitely not subscribed".** This block is the one part of
1721
+ a device configuration with no validity marker of its own, so a sender with
1722
+ nothing to say sends zeros and every receiver stores them. A sender leaves it
1723
+ empty whenever its own stream configuration does not resolve, which covers
1724
+ more than having no route: a selected source whose record has not arrived
1725
+ yet, the state after a restart at either end, missing audio bay
1726
+ configuration, or a stream failing its validity check. ``audio_fmt`` has a
1727
+ second gate of its own, so it can be absent while the addresses are not.
1728
+
1729
+ Expect that reading rather than guarding against it. Any scaling change makes
1730
+ the device rebuild and rebroadcast this block, and a write aimed at a remote
1731
+ bay sends it zeroed however it was requested, so an empty reading turns up
1732
+ most often during exactly the no-signal troubleshooting that prompted the
1733
+ change. A device's periodic report puts a real route back within a minute of
1734
+ it having one, so a reader that needs certainty should wait one out rather
1735
+ than treat the first empty reading as an answer.
1736
+
1737
+ An empty reading is cached rather than dropped on purpose. A sink that has
1738
+ genuinely lost its route sends the same zeros, and so does every report after
1739
+ it, so refusing them would hold a route that nothing later could clear.
1630
1740
  """
1631
1741
  def __init__(self, addresses:V2IPStreamSources|None, audio_fmt:V2IPAudioFormat|None) -> None:
1632
1742
  self._addresses = addresses
@@ -1956,6 +2066,17 @@ class DeviceBase(ABC):
1956
2066
  def mesh_master(self, master:MxrDeviceUid) -> None:
1957
2067
  '''Change the master device of this device'''
1958
2068
 
2069
+ @property
2070
+ @abstractmethod
2071
+ def mesh_master_uid(self) -> MxrDeviceUid|None:
2072
+ '''UID of the mesh controller this device reports, None while it has named none.
2073
+
2074
+ Distinct from mesh_master, which falls back to the device itself. A
2075
+ device that has named no controller is not a device that follows nobody:
2076
+ a client that has just started knows nothing about anyone for as long as
2077
+ a broadcast period.
2078
+ '''
2079
+
1959
2080
  @property
1960
2081
  @abstractmethod
1961
2082
  def protocol(self) -> int:
@@ -1978,6 +2099,31 @@ class DeviceBase(ABC):
1978
2099
  def is_mesh_master(self) -> bool:
1979
2100
  '''True if this device is the master device of a V2IP mesh'''
1980
2101
 
2102
+ @property
2103
+ @abstractmethod
2104
+ def is_management(self) -> bool:
2105
+ '''True when a receiver would let this device write another device's configuration.
2106
+
2107
+ Two bits, because the two kinds of writer announce themselves
2108
+ differently and neither implies the other. MANAGER belongs to external
2109
+ management applications - anything driving these devices that is not one
2110
+ of them, this library included - and no device firmware ever sets it on
2111
+ itself, so testing it alone would refuse every write a controller makes.
2112
+ A device that is controlling its mesh announces MESH_MASTER instead.
2113
+
2114
+ The bits have a hole a caller has to close elsewhere: a device sets
2115
+ MESH_MASTER only while it is both the controller and has bays mapped, so
2116
+ one promoted before it has any carries neither bit while still being its
2117
+ mesh's controller. What covers that window is the controller uid the
2118
+ devices in that mesh report, which is their question rather than this
2119
+ device's - see mesh_master_uid.
2120
+ '''
2121
+
2122
+ @property
2123
+ @abstractmethod
2124
+ def is_v2ip_sink(self) -> bool:
2125
+ '''True if this device decodes a V2IP stream, which is what carries the scaling settings'''
2126
+
1981
2127
  @property
1982
2128
  @abstractmethod
1983
2129
  def is_mesh_member(self) -> bool:
@@ -2106,6 +2252,18 @@ class DeviceBase(ABC):
2106
2252
  async def read_stats(self, enable:bool) -> bool:
2107
2253
  '''start or stop dumping stats'''
2108
2254
 
2255
+ @abstractmethod
2256
+ async def set_v2ip_auto_scaling(self, enabled:bool) -> bool:
2257
+ '''turn this sink's automatic scaling on or off'''
2258
+
2259
+ @abstractmethod
2260
+ async def set_v2ip_output_mode(self, mode:'V2IPOutputMode') -> bool:
2261
+ '''set the output format this sink scales to'''
2262
+
2263
+ @abstractmethod
2264
+ async def clear_v2ip_output_mode(self) -> bool:
2265
+ '''clear the output format this sink is configured to scale to'''
2266
+
2109
2267
  @abstractmethod
2110
2268
  async def get_log(self) -> str|None:
2111
2269
  '''read the log from the device and return it as string'''
@@ -4,7 +4,7 @@ from .Uid import MxrBayUid as MxrBayUid, MxrDeviceUid as MxrDeviceUid
4
4
  from .proto import RCKey as RCKey
5
5
  from .proto.Data import VolumeMuteStatus as VolumeMuteStatus
6
6
  from .proto.Multiviewer import MultiviewerAspectRatio as MultiviewerAspectRatio, MultiviewerBoolSetting as MultiviewerBoolSetting, MultiviewerEDIDTemplate as MultiviewerEDIDTemplate, MultiviewerHDCPMode as MultiviewerHDCPMode, MultiviewerITCMode as MultiviewerITCMode, MultiviewerOutputMode as MultiviewerOutputMode, MultiviewerPipPosition as MultiviewerPipPosition, MultiviewerPipSize as MultiviewerPipSize, MultiviewerSource as MultiviewerSource, MultiviewerViewMode as MultiviewerViewMode
7
- from .proto.Svd import SvdMap as SvdMap
7
+ from .proto.Svd import SvdMap as SvdMap, lookup_svd as lookup_svd
8
8
  from .proto.V2IPStats import V2IPDeviceStats as V2IPDeviceStats
9
9
  from _typeshed import Incomplete
10
10
  from abc import ABC, abstractmethod
@@ -582,11 +582,26 @@ class DeviceV2IPScalingSettings(ABC):
582
582
  @property
583
583
  @abstractmethod
584
584
  def flags(self) -> int: ...
585
+ @property
586
+ def configured_mode(self) -> tuple[MxrSignalType, int] | None: ...
587
+ @property
588
+ def auto_scaling(self) -> bool | None: ...
585
589
 
586
590
  def v2ip_stream_valid(stream: V2IPStreamSource | None) -> bool: ...
587
591
  def v2ip_av_source_valid(video: V2IPStreamSource | None, anc: V2IPStreamSource | None) -> bool: ...
588
592
  def v2ip_stream_cleared(stream: V2IPStreamSource | None) -> bool: ...
589
593
 
594
+ class V2IPOutputMode:
595
+ svd: Incomplete
596
+ depth: Incomplete
597
+ colour: Incomplete
598
+ refresh: Incomplete
599
+ def __init__(self, svd: int, depth: int, colour: VideoColourSpace, refresh: int) -> None: ...
600
+ def validate(self) -> str | None: ...
601
+ @property
602
+ def signal_type(self) -> MxrSignalType: ...
603
+ def __eq__(self, other: Any) -> bool: ...
604
+
590
605
  class V2IPScalingSettings(DeviceV2IPScalingSettings):
591
606
  def __init__(self, mode: int, refresh: int, flags: int) -> None: ...
592
607
  @property
@@ -825,6 +840,9 @@ class DeviceBase(ABC):
825
840
  def mesh_master(self, master: MxrDeviceUid) -> None: ...
826
841
  @property
827
842
  @abstractmethod
843
+ def mesh_master_uid(self) -> MxrDeviceUid | None: ...
844
+ @property
845
+ @abstractmethod
828
846
  def protocol(self) -> int: ...
829
847
  @abstractmethod
830
848
  def supports_opcode(self, opcode: int) -> bool: ...
@@ -833,6 +851,12 @@ class DeviceBase(ABC):
833
851
  def is_mesh_master(self) -> bool: ...
834
852
  @property
835
853
  @abstractmethod
854
+ def is_management(self) -> bool: ...
855
+ @property
856
+ @abstractmethod
857
+ def is_v2ip_sink(self) -> bool: ...
858
+ @property
859
+ @abstractmethod
836
860
  def is_mesh_member(self) -> bool: ...
837
861
  @property
838
862
  @abstractmethod
@@ -899,6 +923,12 @@ class DeviceBase(ABC):
899
923
  @abstractmethod
900
924
  async def read_stats(self, enable: bool) -> bool: ...
901
925
  @abstractmethod
926
+ async def set_v2ip_auto_scaling(self, enabled: bool) -> bool: ...
927
+ @abstractmethod
928
+ async def set_v2ip_output_mode(self, mode: V2IPOutputMode) -> bool: ...
929
+ @abstractmethod
930
+ async def clear_v2ip_output_mode(self) -> bool: ...
931
+ @abstractmethod
902
932
  async def get_log(self) -> str | None: ...
903
933
  @abstractmethod
904
934
  def on_mxr_update(self, data: object) -> None: ...
@@ -14,7 +14,7 @@ Default communication modes:
14
14
  """
15
15
  import os
16
16
 
17
- VERSION = '5.6.0'
17
+ VERSION = '5.8.0'
18
18
  __version__ = VERSION
19
19
 
20
20
  MX_BCAST_UDP_PORT = 8811
@@ -146,6 +146,12 @@ MXR_SCALING_FLAG_AUTO_SCALING = (1 << 7)
146
146
  """mxr_scaling_config.flags. The mode/refresh pair and the options nibble are
147
147
  separately valid: a sender carrying neither flag is offering no scaling at all."""
148
148
 
149
+ V2IP_SCALING_REFRESH_MIN = 24
150
+ V2IP_SCALING_REFRESH_MAX = 120
151
+ """Refresh rate range a V2IP output stage accepts, in Hz. A receiver replaces
152
+ anything outside it with 50 rather than refusing the write, so a 0 here asks
153
+ for 50Hz instead of asking for nothing."""
154
+
149
155
  MXR_V2IP_DSCP_SET = 0x80
150
156
  """DSCP 0 (CS0) is a legal marking, so a zero byte cannot mean "absent": each
151
157
  dscp byte in a V2IP_DEVICE_CFG options word carries this bit alongside its
@@ -379,6 +385,23 @@ def mxr_sig_bpp_get(bpp: int) -> int:
379
385
  bit depth is a common trap. Returns 0 for both unknown and unset.'''
380
386
  return _MXR_SIG_BPP_VALUES.get(bpp, MXR_SIG_BPP_UNKNOWN)
381
387
 
388
+ _MXR_SIG_BPP_WRITABLE_INDICES: dict[int, int] = {
389
+ 8: 1,
390
+ 10: 2,
391
+ 12: 3,
392
+ }
393
+
394
+ def mxr_sig_bpp_index(depth: int) -> int|None:
395
+ '''Map a bit depth to the mxr_signal_type bpp *index* that stands for it.
396
+
397
+ Only the three depths a V2IP output stage accepts are here. Index 4 names
398
+ 16bpp, which mxr_sig_bpp_get() reads back from a device, but the output
399
+ stage refuses it - offering it as something to write would build a frame
400
+ that is decoded cleanly and then dropped in silence.
401
+
402
+ Returns None for a depth no index names.'''
403
+ return _MXR_SIG_BPP_WRITABLE_INDICES.get(depth)
404
+
382
405
  class MxrSignalType:
383
406
  '''The 2-byte mxr_signal_type carried in scaling configs and bay signal reports.
384
407
 
@@ -391,6 +414,26 @@ class MxrSignalType:
391
414
  self._svd = int(data[0])
392
415
  self._flags = int(data[1])
393
416
 
417
+ @staticmethod
418
+ def from_parts(svd: int, colour: int, bpp_index: int) -> 'MxrSignalType':
419
+ '''Build the word a scaling write carries, from the fields it consumes.
420
+
421
+ non_int is left clear: the receiving struct carries the bit and the
422
+ apply path does not read it.
423
+
424
+ Building rather than editing is the point. A sink with no mode
425
+ configured reports the word with the unset bpp index in it, so a caller
426
+ that read that word back and filled in an svd would send an index no
427
+ depth uses, which the receiver decodes to zero and rejects without
428
+ answering.'''
429
+ flags = (colour & 0xF) | ((bpp_index & 0x7) << 5)
430
+ return MxrSignalType(bytes([svd & 0xFF, flags]))
431
+
432
+ @property
433
+ def byte_value(self) -> bytes:
434
+ '''The two bytes as they sit on the wire.'''
435
+ return bytes([self._svd, self._flags])
436
+
394
437
  @property
395
438
  def value(self) -> int:
396
439
  '''Raw 16-bit value.'''
@@ -18,6 +18,8 @@ def v2ip_rate_valid(rate: int | None) -> bool: ...
18
18
  MXR_SCALING_FLAG_MODE_VALID: Incomplete
19
19
  MXR_SCALING_FLAG_OPTIONS_VALID: Incomplete
20
20
  MXR_SCALING_FLAG_AUTO_SCALING: Incomplete
21
+ V2IP_SCALING_REFRESH_MIN: int
22
+ V2IP_SCALING_REFRESH_MAX: int
21
23
  MXR_V2IP_DSCP_SET: int
22
24
  V2IP_DSCP_MAX: int
23
25
  V2IP_DSCP_DEFAULT: int
@@ -108,9 +110,14 @@ MXR_SIG_BPP_UNKNOWN: int
108
110
  MXR_SIG_BPP_UNSET: int
109
111
 
110
112
  def mxr_sig_bpp_get(bpp: int) -> int: ...
113
+ def mxr_sig_bpp_index(depth: int) -> int | None: ...
111
114
 
112
115
  class MxrSignalType:
113
116
  def __init__(self, data: bytes) -> None: ...
117
+ @staticmethod
118
+ def from_parts(svd: int, colour: int, bpp_index: int) -> MxrSignalType: ...
119
+ @property
120
+ def byte_value(self) -> bytes: ...
114
121
  @property
115
122
  def value(self) -> int: ...
116
123
  @property
@@ -7,12 +7,15 @@
7
7
  '''Protocol frame for V2IP device configuration (stream addresses, scaling, options).'''
8
8
 
9
9
  from functools import cached_property
10
+ from typing import Any
10
11
  from .FrameBase import FrameBase
11
12
  from .FrameHeader import FrameHeader
12
13
  from ..Uid import MxrDeviceUid
13
- from ..Interface import DeviceV2IPDetails, DeviceV2IPScalingSettings, DeviceV2IPSink, V2IPAudioFormat, V2IPDscpConfig, V2IPStreamSource
14
+ from ..Interface import (DeviceBase, DeviceRegistry, DeviceV2IPDetails, DeviceV2IPScalingSettings,
15
+ DeviceV2IPSink, V2IPAudioFormat, V2IPDscpConfig, V2IPStreamSource)
14
16
  from .Constants import (MXR_SCALING_FLAG_AUTO_SCALING, MXR_SCALING_FLAG_MODE_VALID,
15
- MXR_SCALING_FLAG_OPTIONS_VALID, v2ip_dscp_value, v2ip_rate_valid)
17
+ MXR_SCALING_FLAG_OPTIONS_VALID, MxrSignalType, v2ip_dscp_value,
18
+ v2ip_rate_valid)
16
19
  from .V2IPConfig import V2IPStreamSourceImpl, parse_v2ip_av_source
17
20
 
18
21
  # v2ip_device_config_update wire layout (little-endian, ALIGN(8) per inner struct):
@@ -120,6 +123,15 @@ class V2IPScalingSettingsImpl(DeviceV2IPScalingSettings):
120
123
  def flags(self) -> int:
121
124
  return self._flags
122
125
 
126
+ _OPCODE = 0x3C
127
+
128
+ _RATE_UNSET = 0xFF
129
+ '''The tx_rate a frame that is not setting a rate carries.
130
+
131
+ The field's valid range ends at V2IP_SOURCE_RATE_MAX, and a receiver drops an
132
+ out-of-range rate and keeps the one it had. A plain zero would ask for a rate of
133
+ zero.'''
134
+
123
135
  class FrameV2IPDeviceConfiguration(FrameBase):
124
136
  '''V2IP device configuration with stream addresses and scaling settings.'''
125
137
  def __init__(self, header:FrameHeader, timestamp:float):
@@ -133,6 +145,57 @@ class FrameV2IPDeviceConfiguration(FrameBase):
133
145
  self.arc = V2IPStreamSourceImpl("arc", self.payload[48:54])
134
146
  self.scaling = V2IPScalingSettingsImpl(self.payload[56:61])
135
147
 
148
+ @staticmethod
149
+ def construct_scaling(mxr:DeviceRegistry, target:Any, target_uid:MxrDeviceUid,
150
+ mode:MxrSignalType, refresh:int, flags:int) -> FrameBase|None:
151
+ '''Build the 0x3C write that moves one sink's scaling block and nothing else.
152
+
153
+ 88 bytes, which is both the receiver's minimum and the whole of
154
+ v2ip_device_config_update: uid 0..16, source 16..40, the options word at
155
+ 40, audio return 48..56, scaling 56..64, tiling 64..88.
156
+
157
+ **88 rather than the 120-byte form.** The longer form appends a sink
158
+ block, and a receiver copies that block into its record for the target
159
+ with no validity test of its own - unlike the source, rate, marking,
160
+ scaling and tiling fields, which each sit behind one. This frame is a
161
+ broadcast, so every device on the network runs that copy, not just the
162
+ addressee: sending the long form with the block zeroed would replace the
163
+ whole network's idea of where the target's sink is subscribed, as a side
164
+ effect of setting one scaling flag. At 88 the block is absent rather
165
+ than zeroed and nothing reads it.
166
+
167
+ **The source block at 16..40 must stay zeroed**, and does. A receiver
168
+ hands this frame's addresses to its encoder unconditionally, on every
169
+ frame it applies rather than only on the ones that carry addresses; what
170
+ stops a scaling write from repointing the encoder is that the call
171
+ refuses a video address which is not multicast. Zero is not multicast.
172
+ Anything that is, written here, would move a transceiver's stream.
173
+
174
+ Which halves of the scaling block a receiver reads is chosen by the
175
+ validity bits in flags, not by this layout: the mode and refresh are
176
+ read behind MXR_SCALING_FLAG_MODE_VALID and the options behind
177
+ MXR_SCALING_FLAG_OPTIONS_VALID, so a write that carries neither bit
178
+ lands as a no-op rather than as a request to zero the settings.
179
+ '''
180
+ payload = bytearray(target_uid.byte_value)
181
+ if (len(payload) != 16):
182
+ raise ValueError(f"invalid uid length: {len(payload)}")
183
+ # source: three stream slots, left zeroed so the encoder keeps its own.
184
+ payload += bytes(40 - len(payload))
185
+ payload.append(_RATE_UNSET)
186
+ # Three dscp bytes with no MXR_V2IP_DSCP_SET bit, so no marking is
187
+ # applied, then the padding that aligns the audio-return slot, then the
188
+ # audio return itself, zeroed, which reads as carrying no address.
189
+ payload += bytes(56 - len(payload))
190
+ payload += mode.byte_value
191
+ payload += refresh.to_bytes(2, 'little')
192
+ payload.append(flags & 0xFF)
193
+ # The scaling struct is 8-aligned, so its five bytes of fields are
194
+ # followed by three of padding; then the tiling window, whose zero uid
195
+ # is what says no window is carried.
196
+ payload += bytes(_BASE_SIZE - len(payload))
197
+ return FrameBase.construct_base(target=target, mxr=mxr, opcode=_OPCODE, payload=bytes(payload))
198
+
136
199
  @property
137
200
  def target_uid(self) -> MxrDeviceUid|None:
138
201
  return self.payload_uuid(idx=0)
@@ -141,6 +204,43 @@ class FrameV2IPDeviceConfiguration(FrameBase):
141
204
  def target_self(self) -> bool:
142
205
  return (self.remote_id == self.target_uid)
143
206
 
207
+ @cached_property
208
+ def subject_device(self) -> DeviceBase|None:
209
+ '''The device this configuration is about, None when nothing would act on it.
210
+
211
+ The payload names its subject in the first sixteen bytes, and that is
212
+ what decides whose configuration this is - not who sent it. Equal to the
213
+ sender it is a device describing itself, which is what almost every one
214
+ of these frames is. Different, it is a write for the device it names,
215
+ and a receiver takes one only from a sender it treats as management.
216
+ Anything else is dropped rather than filed against the sender, because a
217
+ frame the network ignores must not move a record here.
218
+ '''
219
+ if ((subject := self.target_uid) is None):
220
+ return None
221
+ if (subject == self.remote_id):
222
+ return self.remote_device
223
+ # An unknown subject is dropped, as it is on a device: there is no
224
+ # record to move, and inventing one from a third party's description
225
+ # would create a device nothing has been heard from.
226
+ if ((device := self.mxr.get_by_uid(subject)) is None):
227
+ return None
228
+ sender = self.remote_device
229
+ if ((sender is not None) and sender.is_management):
230
+ return device
231
+ # Or the subject naming this sender as its mesh controller. Asked of the
232
+ # subject rather than the sender because it closes the window where a
233
+ # controller has been promoted but has no bays mapped yet, and so
234
+ # announces neither bit while still being the controller its mesh obeys.
235
+ #
236
+ # A subject that has named no controller contributes nothing here rather
237
+ # than refusing: not knowing who a device follows is not knowing that it
238
+ # follows nobody, and a client that has just started knows nothing about
239
+ # anyone for as long as a broadcast period.
240
+ if (device.mesh_master_uid == self.remote_id):
241
+ return device
242
+ return None
243
+
144
244
  @cached_property
145
245
  def details(self) -> DeviceV2IPDetails:
146
246
  return DeviceV2IPDetails(video=self.video, audio=self.audio, anc=self.anc, arc=self.arc, tx_rate=self.options.tx_rate, scaling=self.scaling, dscp=self.options.dscp)
@@ -156,8 +256,8 @@ class FrameV2IPDeviceConfiguration(FrameBase):
156
256
  )
157
257
 
158
258
  def process(self) -> None:
159
- '''Update the local device cache with V2IP configuration details.'''
160
- if ((dev := self.remote_device) is None):
259
+ '''Update the cache of the device this configuration names, not of its sender.'''
260
+ if ((dev := self.subject_device) is None):
161
261
  return
162
262
  dev.on_mxr_update(self.details)
163
263
  if ((sink := self.sink) is not None):
@@ -1,11 +1,12 @@
1
- from ..Interface import DeviceV2IPDetails as DeviceV2IPDetails, DeviceV2IPScalingSettings as DeviceV2IPScalingSettings, DeviceV2IPSink as DeviceV2IPSink, V2IPAudioFormat as V2IPAudioFormat, V2IPDscpConfig as V2IPDscpConfig, V2IPStreamSource as V2IPStreamSource
1
+ from ..Interface import DeviceBase as DeviceBase, DeviceRegistry as DeviceRegistry, DeviceV2IPDetails as DeviceV2IPDetails, DeviceV2IPScalingSettings as DeviceV2IPScalingSettings, DeviceV2IPSink as DeviceV2IPSink, V2IPAudioFormat as V2IPAudioFormat, V2IPDscpConfig as V2IPDscpConfig, V2IPStreamSource as V2IPStreamSource
2
2
  from ..Uid import MxrDeviceUid as MxrDeviceUid
3
- from .Constants import MXR_SCALING_FLAG_AUTO_SCALING as MXR_SCALING_FLAG_AUTO_SCALING, MXR_SCALING_FLAG_MODE_VALID as MXR_SCALING_FLAG_MODE_VALID, MXR_SCALING_FLAG_OPTIONS_VALID as MXR_SCALING_FLAG_OPTIONS_VALID, v2ip_dscp_value as v2ip_dscp_value, v2ip_rate_valid as v2ip_rate_valid
3
+ from .Constants import MXR_SCALING_FLAG_AUTO_SCALING as MXR_SCALING_FLAG_AUTO_SCALING, MXR_SCALING_FLAG_MODE_VALID as MXR_SCALING_FLAG_MODE_VALID, MXR_SCALING_FLAG_OPTIONS_VALID as MXR_SCALING_FLAG_OPTIONS_VALID, MxrSignalType as MxrSignalType, v2ip_dscp_value as v2ip_dscp_value, v2ip_rate_valid as v2ip_rate_valid
4
4
  from .FrameBase import FrameBase as FrameBase
5
5
  from .FrameHeader import FrameHeader as FrameHeader
6
6
  from .V2IPConfig import V2IPStreamSourceImpl as V2IPStreamSourceImpl, parse_v2ip_av_source as parse_v2ip_av_source
7
7
  from _typeshed import Incomplete
8
8
  from functools import cached_property
9
+ from typing import Any
9
10
 
10
11
  class V2IPDeviceOptions:
11
12
  def __init__(self, data: bytes) -> None: ...
@@ -33,11 +34,15 @@ class FrameV2IPDeviceConfiguration(FrameBase):
33
34
  arc: Incomplete
34
35
  scaling: Incomplete
35
36
  def __init__(self, header: FrameHeader, timestamp: float) -> None: ...
37
+ @staticmethod
38
+ def construct_scaling(mxr: DeviceRegistry, target: Any, target_uid: MxrDeviceUid, mode: MxrSignalType, refresh: int, flags: int) -> FrameBase | None: ...
36
39
  @property
37
40
  def target_uid(self) -> MxrDeviceUid | None: ...
38
41
  @property
39
42
  def target_self(self) -> bool: ...
40
43
  @cached_property
44
+ def subject_device(self) -> DeviceBase | None: ...
45
+ @cached_property
41
46
  def details(self) -> DeviceV2IPDetails: ...
42
47
  @cached_property
43
48
  def sink(self) -> DeviceV2IPSink | None: ...