mx-remote 8.0.2__tar.gz → 8.1.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 (268) hide show
  1. {mx_remote-8.0.2 → mx_remote-8.1.0}/PKG-INFO +1 -1
  2. {mx_remote-8.0.2 → mx_remote-8.1.0}/docs/api.md +12 -0
  3. {mx_remote-8.0.2 → mx_remote-8.1.0}/docs/oneip.md +25 -1
  4. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/Interface.py +64 -0
  5. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/Interface.pyi +24 -0
  6. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/const.py +1 -1
  7. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/Constants.py +9 -3
  8. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/Constants.pyi +1 -0
  9. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/Factory.py +3 -0
  10. mx_remote-8.1.0/mx_remote/proto/FrameV2IPPrivacy.py +83 -0
  11. mx_remote-8.1.0/mx_remote/proto/FrameV2IPPrivacy.pyi +22 -0
  12. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/remote/Device.py +25 -0
  13. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/remote/Device.pyi +6 -1
  14. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/remote/Remote.py +11 -1
  15. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/remote/Remote.pyi +2 -0
  16. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/handlers.py +1 -0
  17. mx_remote-8.1.0/tests/privacy.py +112 -0
  18. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/proto_stamp.py +1 -0
  19. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/run.py +1 -0
  20. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/smoke.py +1 -0
  21. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/v2ipsettings.py +10 -0
  22. {mx_remote-8.0.2 → mx_remote-8.1.0}/.gitignore +0 -0
  23. {mx_remote-8.0.2 → mx_remote-8.1.0}/AGENTS.md +0 -0
  24. {mx_remote-8.0.2 → mx_remote-8.1.0}/CLAUDE.md +0 -0
  25. {mx_remote-8.0.2 → mx_remote-8.1.0}/LICENSE +0 -0
  26. {mx_remote-8.0.2 → mx_remote-8.1.0}/README.md +0 -0
  27. {mx_remote-8.0.2 → mx_remote-8.1.0}/docs/README.md +0 -0
  28. {mx_remote-8.0.2 → mx_remote-8.1.0}/docs/_static/brand.css +0 -0
  29. {mx_remote-8.0.2 → mx_remote-8.1.0}/docs/_static/favicon.svg +0 -0
  30. {mx_remote-8.0.2 → mx_remote-8.1.0}/docs/_static/logo-dark.svg +0 -0
  31. {mx_remote-8.0.2 → mx_remote-8.1.0}/docs/_static/logo.svg +0 -0
  32. {mx_remote-8.0.2 → mx_remote-8.1.0}/docs/audio.md +0 -0
  33. {mx_remote-8.0.2 → mx_remote-8.1.0}/docs/callbacks.md +0 -0
  34. {mx_remote-8.0.2 → mx_remote-8.1.0}/docs/conf.py +0 -0
  35. {mx_remote-8.0.2 → mx_remote-8.1.0}/docs/configuration.md +0 -0
  36. {mx_remote-8.0.2 → mx_remote-8.1.0}/docs/devices-and-bays.md +0 -0
  37. {mx_remote-8.0.2 → mx_remote-8.1.0}/docs/diagnostics.md +0 -0
  38. {mx_remote-8.0.2 → mx_remote-8.1.0}/docs/index.md +0 -0
  39. {mx_remote-8.0.2 → mx_remote-8.1.0}/docs/multiviewer.md +0 -0
  40. {mx_remote-8.0.2 → mx_remote-8.1.0}/docs/requirements.txt +0 -0
  41. {mx_remote-8.0.2 → mx_remote-8.1.0}/docs/routing.md +0 -0
  42. {mx_remote-8.0.2 → mx_remote-8.1.0}/hatch_build.py +0 -0
  43. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/Uid.py +0 -0
  44. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/Uid.pyi +0 -0
  45. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/__init__.py +0 -0
  46. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/__init__.pyi +0 -0
  47. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/compat.py +0 -0
  48. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/compat.pyi +0 -0
  49. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/const.pyi +0 -0
  50. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/deprecated.py +0 -0
  51. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/deprecated.pyi +0 -0
  52. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/main.py +0 -0
  53. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/main.pyi +0 -0
  54. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/BayConfig.py +0 -0
  55. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/BayConfig.pyi +0 -0
  56. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/Data.py +0 -0
  57. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/Data.pyi +0 -0
  58. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/Factory.pyi +0 -0
  59. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameAmpDolbySettings.py +0 -0
  60. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameAmpDolbySettings.pyi +0 -0
  61. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameAmpZoneSettings.py +0 -0
  62. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameAmpZoneSettings.pyi +0 -0
  63. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameAudioClip.py +0 -0
  64. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameAudioClip.pyi +0 -0
  65. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameAudioSetRoute.py +0 -0
  66. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameAudioSetRoute.pyi +0 -0
  67. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameBase.py +0 -0
  68. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameBase.pyi +0 -0
  69. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameBayConfig.py +0 -0
  70. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameBayConfig.pyi +0 -0
  71. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameBayConfigSecondary.py +0 -0
  72. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameBayConfigSecondary.pyi +0 -0
  73. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameBayContent.py +0 -0
  74. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameBayContent.pyi +0 -0
  75. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameBayHide.py +0 -0
  76. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameBayHide.pyi +0 -0
  77. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameBayStatus.py +0 -0
  78. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameBayStatus.pyi +0 -0
  79. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameConnectStatus.py +0 -0
  80. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameConnectStatus.pyi +0 -0
  81. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameDebug.py +0 -0
  82. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameDebug.pyi +0 -0
  83. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameDiscover.py +0 -0
  84. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameDiscover.pyi +0 -0
  85. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameEDID.py +0 -0
  86. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameEDID.pyi +0 -0
  87. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameEDIDProfile.py +0 -0
  88. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameEDIDProfile.pyi +0 -0
  89. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameFactoryReset.py +0 -0
  90. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameFactoryReset.pyi +0 -0
  91. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameFilterStatus.py +0 -0
  92. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameFilterStatus.pyi +0 -0
  93. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameFirmwareVersion.py +0 -0
  94. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameFirmwareVersion.pyi +0 -0
  95. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameHeader.py +0 -0
  96. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameHeader.pyi +0 -0
  97. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameHello.py +0 -0
  98. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameHello.pyi +0 -0
  99. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameLinks.py +0 -0
  100. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameLinks.pyi +0 -0
  101. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameMeshOperation.py +0 -0
  102. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameMeshOperation.pyi +0 -0
  103. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameMirrorStatus.py +0 -0
  104. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameMirrorStatus.pyi +0 -0
  105. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameMonitoringPulse.py +0 -0
  106. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameMonitoringPulse.pyi +0 -0
  107. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameNetworkStatus.py +0 -0
  108. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameNetworkStatus.pyi +0 -0
  109. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FramePing.py +0 -0
  110. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FramePing.pyi +0 -0
  111. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FramePowerChange.py +0 -0
  112. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FramePowerChange.pyi +0 -0
  113. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameRCAction.py +0 -0
  114. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameRCAction.pyi +0 -0
  115. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameRCIr.py +0 -0
  116. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameRCIr.pyi +0 -0
  117. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameRCKey.py +0 -0
  118. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameRCKey.pyi +0 -0
  119. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameRCSettings.py +0 -0
  120. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameRCSettings.pyi +0 -0
  121. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameReboot.py +0 -0
  122. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameReboot.pyi +0 -0
  123. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameRoutingChange.py +0 -0
  124. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameRoutingChange.pyi +0 -0
  125. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameSetInstaller.py +0 -0
  126. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameSetInstaller.pyi +0 -0
  127. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameSetName.py +0 -0
  128. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameSetName.pyi +0 -0
  129. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameSetRoute.py +0 -0
  130. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameSetRoute.pyi +0 -0
  131. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameSetupStatus.py +0 -0
  132. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameSetupStatus.pyi +0 -0
  133. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameSignalStatus.py +0 -0
  134. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameSignalStatus.pyi +0 -0
  135. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameSignalStatusNew.py +0 -0
  136. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameSignalStatusNew.pyi +0 -0
  137. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameSysTemperature.py +0 -0
  138. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameSysTemperature.pyi +0 -0
  139. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameSystemStatus.py +0 -0
  140. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameSystemStatus.pyi +0 -0
  141. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameTXRCAction.py +0 -0
  142. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameTXRCAction.pyi +0 -0
  143. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameTXRCKey.py +0 -0
  144. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameTXRCKey.pyi +0 -0
  145. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameTime.py +0 -0
  146. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameTime.pyi +0 -0
  147. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameTimeZone.py +0 -0
  148. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameTimeZone.pyi +0 -0
  149. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameTopology.py +0 -0
  150. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameTopology.pyi +0 -0
  151. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameTxIR.py +0 -0
  152. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameTxIR.pyi +0 -0
  153. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameUnitCommand.py +0 -0
  154. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameUnitCommand.pyi +0 -0
  155. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameUnitStatus.py +0 -0
  156. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameUnitStatus.pyi +0 -0
  157. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameUpgradeFPGA.py +0 -0
  158. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameUpgradeFPGA.pyi +0 -0
  159. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPAudio.py +0 -0
  160. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPAudio.pyi +0 -0
  161. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPBayMapping.py +0 -0
  162. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPBayMapping.pyi +0 -0
  163. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPDetectBays.py +0 -0
  164. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPDetectBays.pyi +0 -0
  165. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPDeviceConfiguration.py +0 -0
  166. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPDeviceConfiguration.pyi +0 -0
  167. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPLink.py +0 -0
  168. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPLink.pyi +0 -0
  169. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPManualSourceSwitch.py +0 -0
  170. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPManualSourceSwitch.pyi +0 -0
  171. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPMultiviewer.py +0 -0
  172. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPMultiviewer.pyi +0 -0
  173. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPOsdSync.py +0 -0
  174. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPOsdSync.pyi +0 -0
  175. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPPowerSave.py +0 -0
  176. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPPowerSave.pyi +0 -0
  177. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPSetMaster.py +0 -0
  178. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPSetMaster.pyi +0 -0
  179. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPSettingsAll.py +0 -0
  180. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPSettingsAll.pyi +0 -0
  181. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPSinkSdp.py +0 -0
  182. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPSinkSdp.pyi +0 -0
  183. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPSourceSwitch.py +0 -0
  184. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPSourceSwitch.pyi +0 -0
  185. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPSources.py +0 -0
  186. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPSources.pyi +0 -0
  187. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPStats.py +0 -0
  188. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPStats.pyi +0 -0
  189. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPTestcard.py +0 -0
  190. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPTestcard.pyi +0 -0
  191. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPTiling.py +0 -0
  192. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPTiling.pyi +0 -0
  193. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPVideoWall.py +0 -0
  194. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameV2IPVideoWall.pyi +0 -0
  195. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameVolume.py +0 -0
  196. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameVolume.pyi +0 -0
  197. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameVolumeDown.py +0 -0
  198. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameVolumeDown.pyi +0 -0
  199. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameVolumeSet.py +0 -0
  200. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameVolumeSet.pyi +0 -0
  201. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameVolumeUp.py +0 -0
  202. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/FrameVolumeUp.pyi +0 -0
  203. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/LinkConfig.py +0 -0
  204. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/LinkConfig.pyi +0 -0
  205. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/Multiviewer.py +0 -0
  206. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/Multiviewer.pyi +0 -0
  207. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/Svd.py +0 -0
  208. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/Svd.pyi +0 -0
  209. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/V2IPConfig.py +0 -0
  210. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/V2IPConfig.pyi +0 -0
  211. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/V2IPStats.py +0 -0
  212. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/V2IPStats.pyi +0 -0
  213. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/__init__.py +0 -0
  214. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/__init__.pyi +0 -0
  215. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/proto/svd.csv +0 -0
  216. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/py.typed +0 -0
  217. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/remote/Bay.py +0 -0
  218. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/remote/Bay.pyi +0 -0
  219. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/remote/ConnectionAsync.py +0 -0
  220. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/remote/ConnectionAsync.pyi +0 -0
  221. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/remote/Link.py +0 -0
  222. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/remote/Link.pyi +0 -0
  223. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/remote/OsdSync.py +0 -0
  224. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/remote/OsdSync.pyi +0 -0
  225. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/remote/State.py +0 -0
  226. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/remote/State.pyi +0 -0
  227. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/remote/V2IP.py +0 -0
  228. {mx_remote-8.0.2 → mx_remote-8.1.0}/mx_remote/remote/V2IP.pyi +0 -0
  229. {mx_remote-8.0.2 → mx_remote-8.1.0}/pyproject.toml +0 -0
  230. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/audio_ir.py +0 -0
  231. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/audiolock.py +0 -0
  232. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/baycontent.py +0 -0
  233. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/capture.py +0 -0
  234. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/complete.py +0 -0
  235. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/deprecated.py +0 -0
  236. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/e2e.py +0 -0
  237. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/entry.py +0 -0
  238. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/handlers_cover.py +0 -0
  239. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/hello.py +0 -0
  240. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/idempotent.py +0 -0
  241. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/lengths.py +0 -0
  242. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/merge.py +0 -0
  243. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/mesh.py +0 -0
  244. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/meshbays.py +0 -0
  245. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/newops.py +0 -0
  246. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/offline.py +0 -0
  247. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/offsets.py +0 -0
  248. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/osdsync.py +0 -0
  249. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/ping.py +0 -0
  250. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/protogate.py +0 -0
  251. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/rcsettings.py +0 -0
  252. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/roundtrip.py +0 -0
  253. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/routes.py +0 -0
  254. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/sources.py +0 -0
  255. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/staterequest.py +0 -0
  256. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/stats.py +0 -0
  257. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/structs.py +0 -0
  258. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/txresult.py +0 -0
  259. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/unit.py +0 -0
  260. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/unknowns.py +0 -0
  261. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/v2ipptp.py +0 -0
  262. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/v2ipscaling.py +0 -0
  263. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/v2ipsdp.py +0 -0
  264. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/v2ipsubject.py +0 -0
  265. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/v2iptestcard.py +0 -0
  266. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/v2ipvlan.py +0 -0
  267. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/vdetails.py +0 -0
  268. {mx_remote-8.0.2 → mx_remote-8.1.0}/tests/wirefix.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mx_remote
3
- Version: 8.0.2
3
+ Version: 8.1.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/
@@ -240,6 +240,18 @@ What an output plays from a session description, and how it is timed.
240
240
  .. autodata:: mx_remote.proto.Constants.ONEIP_SDP_MAX
241
241
  ```
242
242
 
243
+ ## OneIP stream encryption
244
+
245
+ How a source unit encrypts its streams, as it reports it.
246
+
247
+ ```{eval-rst}
248
+ .. autoclass:: mx_remote.OneIPPrivacy
249
+ :members:
250
+
251
+ .. autoclass:: mx_remote.OneIPPrivacyStream
252
+ :members:
253
+ ```
254
+
243
255
  ## OneIP test card
244
256
 
245
257
  A sink's test pattern, tone and lip-sync flash, and the ranges it accepts.
@@ -247,6 +247,10 @@ that takes a power save level switches it off, so a level write, this client's
247
247
  or another sender's, clears it unless the same frame states it. Firmware
248
248
  without the bit never reports it, and the switch is refused there.
249
249
 
250
+ `OneIPDeviceSetting.CLOUD` is a switch for whether a device connects to the
251
+ cloud service. A device that does not report it connects regardless, and the
252
+ switch is refused there.
253
+
250
254
  ### PTP
251
255
 
252
256
  A device's PTP mode, domain and grandmaster priority1 are settings like the
@@ -408,10 +412,30 @@ await mx.clear_osd_sync() # stop answering for it
408
412
  Each unit asks for the settings it lacks and takes only those, so this client
409
413
  holds the content and answers until it is replaced or cleared, announcing
410
414
  itself meanwhile as the mesh's overlay sync manager: a unit waking from power
411
- save then takes this content rather than the controller's. Logo images are
415
+ save then takes this content rather than the controller's. The controller
416
+ still answers a unit that asks for settings without naming whose sync they are
417
+ from, as an older overlay module does, with its own sync, which replaces this
418
+ client's on that unit. Logo images are
412
419
  served over HTTP from this client, on `logo_port` or a free port. Content a unit
413
420
  would refuse is refused before anything is sent; see `send_osd_sync`.
414
421
 
422
+ ### Stream encryption
423
+
424
+ A source unit from protocol 0x30 encrypts its video, audio and ancillary
425
+ streams, and reports what a receiver needs to decrypt them whenever that
426
+ changes and at least once a minute. The key itself never travels: every unit of
427
+ a mesh holds the same one, and this library holds none.
428
+
429
+ ```python
430
+ await source.request_oneip_privacy() # report now
431
+ await mx.request_all_oneip_privacy() # every source unit of the mesh
432
+
433
+ print(source.oneip_privacy) # None until the unit has reported
434
+ ```
435
+
436
+ A request to a unit that is not a source, or that is below protocol 0x30, is
437
+ refused before it is sent.
438
+
415
439
  ### Every device at once
416
440
 
417
441
  One broadcast changes settings on every OneIP device of the mesh. Each device
@@ -2471,6 +2471,59 @@ class OneIPSinkSdp:
2471
2471
  return (f"'{self.name}' {self.group}:{self.port} {encoding} {self.channels}ch {self.rate_hz}Hz"
2472
2472
  f"{' audio only' if (OneIPSdpFlag.AUDIO_ONLY in self.flags) else ''}")
2473
2473
 
2474
+ @dataclass(frozen=True)
2475
+ class OneIPPrivacyStream:
2476
+ """
2477
+ How one of a OneIP source unit's streams is encrypted, as the unit reports
2478
+ it: the values of the stream's ``a=privacy`` and ``a=extmap`` session
2479
+ description attributes.
2480
+
2481
+ A receiver needs these to decrypt the stream. The key itself never travels:
2482
+ every unit of a mesh holds the same one. This library holds no key and
2483
+ decrypts nothing.
2484
+ """
2485
+ group: str = '0.0.0.0'
2486
+ """The stream's destination address."""
2487
+ port: int = 0
2488
+ """The stream's UDP port."""
2489
+ version: int = 0
2490
+ """Steps whenever the parameters change."""
2491
+ on: bool = False
2492
+ """Whether the stream is encrypted. Without it the stream goes out in the
2493
+ clear, and the fields below mean nothing."""
2494
+ full_id: int = 0
2495
+ """The RTP header extension id of the full IV counter."""
2496
+ short_id: int = 0
2497
+ """The RTP header extension id of the short IV counter."""
2498
+ iv: bytes = bytes(8)
2499
+ """The initialisation vector, 8 bytes."""
2500
+ key_generator: bytes = bytes(16)
2501
+ """The key generator, 16 bytes."""
2502
+ key_id: bytes = bytes(8)
2503
+ """Names the mesh's key, 8 bytes. A receiver holding another drops the stream."""
2504
+ key_version: int = 0
2505
+ """The key version."""
2506
+
2507
+ def __str__(self) -> str:
2508
+ if not self.on:
2509
+ return f"{self.group}:{self.port} clear"
2510
+ return f"{self.group}:{self.port} encrypted, key {self.key_id.hex()} v{self.key_version}"
2511
+
2512
+ @dataclass(frozen=True)
2513
+ class OneIPPrivacy:
2514
+ """
2515
+ How a OneIP source unit's streams are encrypted, as it last reported it. A
2516
+ unit reports this whenever it changes, at least once a minute, and when
2517
+ asked. A unit that is in no mesh sends in the clear.
2518
+ """
2519
+ video: OneIPPrivacyStream
2520
+ audio: OneIPPrivacyStream
2521
+ anc: OneIPPrivacyStream
2522
+ """The ancillary data stream."""
2523
+
2524
+ def __str__(self) -> str:
2525
+ return f"video {self.video}, audio {self.audio}, anc {self.anc}"
2526
+
2474
2527
  @dataclass(frozen=True)
2475
2528
  class OneIPTestTone:
2476
2529
  """A OneIP sink's test tone."""
@@ -3459,6 +3512,17 @@ class DeviceBase(ABC):
3459
3512
  reported it, None until it has. It reports when that changes and at
3460
3513
  least once a minute.'''
3461
3514
 
3515
+ @property
3516
+ @abstractmethod
3517
+ def oneip_privacy(self) -> OneIPPrivacy|None:
3518
+ '''How this OneIP source unit's streams are encrypted, as it last
3519
+ reported it, None until it has. It reports when that changes and at
3520
+ least once a minute.'''
3521
+
3522
+ @abstractmethod
3523
+ async def request_oneip_privacy(self) -> bool:
3524
+ '''ask this OneIP source unit how its streams are encrypted now'''
3525
+
3462
3526
  @abstractmethod
3463
3527
  async def play_oneip_sdp(self, sdp:str) -> bool:
3464
3528
  '''have this OneIP output play the stream a session description describes'''
@@ -891,6 +891,25 @@ class OneIPSinkSdp:
891
891
  @property
892
892
  def active(self) -> bool: ...
893
893
 
894
+ @dataclass(frozen=True)
895
+ class OneIPPrivacyStream:
896
+ group: str = ...
897
+ port: int = ...
898
+ version: int = ...
899
+ on: bool = ...
900
+ full_id: int = ...
901
+ short_id: int = ...
902
+ iv: bytes = ...
903
+ key_generator: bytes = ...
904
+ key_id: bytes = ...
905
+ key_version: int = ...
906
+
907
+ @dataclass(frozen=True)
908
+ class OneIPPrivacy:
909
+ video: OneIPPrivacyStream
910
+ audio: OneIPPrivacyStream
911
+ anc: OneIPPrivacyStream
912
+
894
913
  @dataclass(frozen=True)
895
914
  class OneIPTestTone:
896
915
  mode: OneIPToneMode | int = ...
@@ -1306,6 +1325,11 @@ class DeviceBase(ABC):
1306
1325
  @property
1307
1326
  @abstractmethod
1308
1327
  def oneip_sink_sdp(self) -> OneIPSinkSdp | None: ...
1328
+ @property
1329
+ @abstractmethod
1330
+ def oneip_privacy(self) -> OneIPPrivacy | None: ...
1331
+ @abstractmethod
1332
+ async def request_oneip_privacy(self) -> bool: ...
1309
1333
  @abstractmethod
1310
1334
  async def play_oneip_sdp(self, sdp: str) -> bool: ...
1311
1335
  @abstractmethod
@@ -14,7 +14,7 @@ Default communication modes:
14
14
  """
15
15
  import os
16
16
 
17
- VERSION = '8.0.2'
17
+ VERSION = '8.1.0'
18
18
  __version__ = VERSION
19
19
 
20
20
  MX_BCAST_UDP_PORT = 8811
@@ -10,7 +10,7 @@
10
10
  from enum import IntEnum, IntFlag
11
11
  from typing import TypeVar
12
12
 
13
- MXR_PROTOCOL_VERSION = 0x2F
13
+ MXR_PROTOCOL_VERSION = 0x30
14
14
  """Highest mx_remote protocol version this library understands.
15
15
 
16
16
  Mirrors the firmware's own protocol version. Bump
@@ -31,7 +31,8 @@ every 20-30s once every device it knows is on 0x2A.
31
31
  0x2B.
32
32
 
33
33
  0x2E is the row of V2IP_SINK_SDP, so an output's session description report
34
- arrives stamped 0x2E, and 0x2F the row of V2IP_OSD_SYNC."""
34
+ arrives stamped 0x2E, 0x2F the row of V2IP_OSD_SYNC and 0x30 the row of
35
+ V2IP_PRIVACY."""
35
36
 
36
37
  MXR_PROTOCOL_VERSION_SLOW_HELLO = 0x2A
37
38
  """First protocol version whose devices may hello only every 20-30s."""
@@ -121,6 +122,7 @@ MXR_OPCODE_VERSIONS: dict[int, int] = {
121
122
  0x51: 0x2D, # BAY_CONTENT
122
123
  0x52: 0x2E, # V2IP_SINK_SDP
123
124
  0x53: 0x2F, # V2IP_OSD_SYNC
125
+ 0x54: 0x30, # V2IP_PRIVACY
124
126
  }
125
127
  """Per-opcode minimum compatible protocol version, mirroring the firmware's
126
128
  own opcode table. Transmitters stamp the
@@ -374,9 +376,13 @@ class OneIPDeviceSetting(IntFlag):
374
376
  STREAM_TTL = (1 << 29)
375
377
  '''The TTL of the device's streams, video, audio and ancillary alike.
376
378
  Reported only by a device with an encoder.'''
379
+ CLOUD = (1 << 30)
380
+ '''Connects to the cloud service. A device that does not report it
381
+ connects regardless.'''
377
382
 
378
383
  ONEIP_DEVICE_SETTING_SWITCHES = OneIPDeviceSetting(0xFF) | OneIPDeviceSetting.AES67 \
379
- | OneIPDeviceSetting.SAP_LISTEN | OneIPDeviceSetting.SAP_ANNOUNCE | OneIPDeviceSetting.POWER_SAVE_DISABLED
384
+ | OneIPDeviceSetting.SAP_LISTEN | OneIPDeviceSetting.SAP_ANNOUNCE | OneIPDeviceSetting.POWER_SAVE_DISABLED \
385
+ | OneIPDeviceSetting.CLOUD
380
386
  """The settings that are on or off, as opposed to carrying a value, and that
381
387
  can be written."""
382
388
 
@@ -108,6 +108,7 @@ class OneIPDeviceSetting(IntFlag):
108
108
  SAP_INTERVAL: Incomplete
109
109
  POWER_SAVE_DISABLED: Incomplete
110
110
  STREAM_TTL: Incomplete
111
+ CLOUD: Incomplete
111
112
 
112
113
  ONEIP_DEVICE_SETTING_SWITCHES: Incomplete
113
114
  ONEIP_DEVICE_SETTINGS_REPORTED_ONLY: Incomplete
@@ -246,6 +246,9 @@ def _mxr_frame_factory(hdr:FrameHeader, timestamp:float) -> FrameBase|None:
246
246
  if hdr.opcode == 0x53:
247
247
  from .FrameV2IPOsdSync import FrameV2IPOsdSync
248
248
  return FrameV2IPOsdSync(header=hdr, timestamp=timestamp)
249
+ if hdr.opcode == 0x54:
250
+ from .FrameV2IPPrivacy import FrameV2IPPrivacy
251
+ return FrameV2IPPrivacy(header=hdr, timestamp=timestamp)
249
252
  if hdr.opcode == 0x50:
250
253
  from .FrameUnitCommand import FrameUnitCommand
251
254
  return FrameUnitCommand(header=hdr, timestamp=timestamp)
@@ -0,0 +1,83 @@
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 V2IP_PRIVACY (opcode 0x54): how a OneIP source unit
8
+ encrypts its streams, and the request that asks it.'''
9
+
10
+ from functools import cached_property
11
+ import ipaddress
12
+ import struct
13
+ from typing import Any
14
+ from ..compat import override
15
+ from .FrameBase import FrameBase
16
+ from ..Interface import DeviceRegistry, OneIPPrivacy, OneIPPrivacyStream
17
+ from ..Uid import MxrDeviceUid
18
+
19
+ # Every frame opens with a uid, the frame type at 16, the count of streams that
20
+ # follow at 17 and two reserved bytes.
21
+ #
22
+ # A report follows with its streams, video, audio and ancillary data first, each
23
+ # PRIVACY_STREAM_SIZE bytes: the IPv4 group at 0, u16 port at 4, the version at
24
+ # 6, a reserved byte, then whether it is encrypted at 8, the two header extension
25
+ # ids at 9 and 10, the 8-byte IV at 11, the 16-byte key generator at 19, the
26
+ # 8-byte key id at 35 and u32 key version at 43, unaligned, and a reserved byte.
27
+ PRIVACY_HEADER_SIZE = 20
28
+ PRIVACY_STREAM_SIZE = 48
29
+ PRIVACY_STREAMS = 3
30
+
31
+ PRIVACY_REPORT = 0
32
+ '''A source unit's report.'''
33
+ PRIVACY_REQUEST = 1
34
+ '''Report now.'''
35
+
36
+ def _stream(s:bytes) -> OneIPPrivacyStream:
37
+ port, = struct.unpack_from('<H', s, 4)
38
+ key_version, = struct.unpack_from('<I', s, 43)
39
+ return OneIPPrivacyStream(group=str(ipaddress.IPv4Address(s[0:4])), port=port, version=s[6],
40
+ on=(s[8] != 0), full_id=s[9], short_id=s[10], iv=bytes(s[11:19]),
41
+ key_generator=bytes(s[19:35]), key_id=bytes(s[35:43]),
42
+ key_version=key_version)
43
+
44
+ class FrameV2IPPrivacy(FrameBase):
45
+ '''A OneIP source unit's report of how it encrypts its streams, or a request
46
+ for one.'''
47
+ @staticmethod
48
+ def construct_request(mxr:DeviceRegistry, target:Any, target_uid:MxrDeviceUid|None) -> FrameBase|None:
49
+ '''A request naming the source unit asked, or the zero uid for every one.'''
50
+ uid = target_uid.byte_value if (target_uid is not None) else bytes(16)
51
+ payload = uid + bytes([PRIVACY_REQUEST, 0, 0, 0])
52
+ return FrameBase.construct_base(target=target, mxr=mxr, opcode=0x54, payload=payload)
53
+
54
+ @cached_property
55
+ def kind(self) -> int|None:
56
+ '''The frame type, None for a frame shorter than its header.'''
57
+ if (len(self.payload or b'') < PRIVACY_HEADER_SIZE):
58
+ return None
59
+ return self.payload_u8(16)
60
+
61
+ @cached_property
62
+ def privacy(self) -> OneIPPrivacy|None:
63
+ '''What the unit reports, None for a request and for a report missing
64
+ any of the three streams.'''
65
+ p = self.payload or b''
66
+ if (self.kind != PRIVACY_REPORT) or (p[17] < PRIVACY_STREAMS) \
67
+ or (len(p) < PRIVACY_HEADER_SIZE + PRIVACY_STREAMS * PRIVACY_STREAM_SIZE):
68
+ return None
69
+ at = lambda i: _stream(p[PRIVACY_HEADER_SIZE + i * PRIVACY_STREAM_SIZE:])
70
+ return OneIPPrivacy(video=at(0), audio=at(1), anc=at(2))
71
+
72
+ @override
73
+ def process(self) -> None:
74
+ '''Record a report against the unit that sent it, whatever uid its
75
+ header names, as units file it. A request is for a unit with an encoder
76
+ to answer, which this client is not.'''
77
+ if ((privacy := self.privacy) is not None) and ((dev := self.remote_device) is not None):
78
+ dev.on_mxr_update(privacy)
79
+
80
+ def __str__(self) -> str:
81
+ if (self.privacy is not None):
82
+ return f"OneIP privacy: {self.privacy}"
83
+ return f"OneIP privacy request for {self.uid_to_user_string(self.payload_uuid(0))}"
@@ -0,0 +1,22 @@
1
+ from ..Interface import DeviceRegistry as DeviceRegistry, OneIPPrivacy as OneIPPrivacy, OneIPPrivacyStream as OneIPPrivacyStream
2
+ from ..Uid import MxrDeviceUid as MxrDeviceUid
3
+ from ..compat import override as override
4
+ from .FrameBase import FrameBase as FrameBase
5
+ from functools import cached_property
6
+ from typing import Any
7
+
8
+ PRIVACY_HEADER_SIZE: int
9
+ PRIVACY_STREAM_SIZE: int
10
+ PRIVACY_STREAMS: int
11
+ PRIVACY_REPORT: int
12
+ PRIVACY_REQUEST: int
13
+
14
+ class FrameV2IPPrivacy(FrameBase):
15
+ @staticmethod
16
+ def construct_request(mxr: DeviceRegistry, target: Any, target_uid: MxrDeviceUid | None) -> FrameBase | None: ...
17
+ @cached_property
18
+ def kind(self) -> int | None: ...
19
+ @cached_property
20
+ def privacy(self) -> OneIPPrivacy | None: ...
21
+ @override
22
+ def process(self) -> None: ...
@@ -31,6 +31,7 @@ from ..Interface import (
31
31
  OneIPVlan,
32
32
  OneIPTestcard,
33
33
  OneIPSinkSdp,
34
+ OneIPPrivacy,
34
35
  OsdSync,
35
36
  OsdSyncReport,
36
37
  OneIPTestSync,
@@ -89,6 +90,7 @@ from ..proto.Constants import (MXR_PROTOCOL_VERSION_SLOW_HELLO, BayFeaturesMask,
89
90
  from ..proto.FrameUnitCommand import FrameUnitCommand
90
91
  from ..proto.FrameV2IPAudio import AUDIO_LAYOUT_SINCE, FrameV2IPAudio
91
92
  from ..proto.FrameV2IPDeviceConfiguration import FrameV2IPDeviceConfiguration
93
+ from ..proto.FrameV2IPPrivacy import FrameV2IPPrivacy
92
94
  from ..proto.FrameV2IPSinkSdp import FrameV2IPSinkSdp, SDP_REQUEST, SDP_SCREEN, SDP_STOP
93
95
  from ..proto.FrameV2IPTestcard import (FrameV2IPTestcard, TESTCARD_REQUEST, TESTCARD_SET,
94
96
  TESTCARD_PART_PATTERN, TESTCARD_PART_TONE, TESTCARD_PART_SYNC)
@@ -171,6 +173,7 @@ class Device(DeviceBase):
171
173
  self._unit_status:UnitStatus|None = None
172
174
  self._v2ip_testcard:OneIPTestcard|None = None
173
175
  self._v2ip_sink_sdp:OneIPSinkSdp|None = None
176
+ self._v2ip_privacy:OneIPPrivacy|None = None
174
177
  self._osd_sync:OsdSync|None = None
175
178
  self._osd_sync_report:OsdSyncReport|None = None
176
179
  self._time_zone:TimeZone|None = None
@@ -1060,6 +1063,10 @@ class Device(DeviceBase):
1060
1063
  if (self._v2ip_sink_sdp != data):
1061
1064
  self._v2ip_sink_sdp = data
1062
1065
  self.call_callbacks()
1066
+ elif isinstance(data, OneIPPrivacy):
1067
+ if (self._v2ip_privacy != data):
1068
+ self._v2ip_privacy = data
1069
+ self.call_callbacks()
1063
1070
  elif isinstance(data, TimeZone):
1064
1071
  if (self._time_zone != data):
1065
1072
  self._time_zone = data
@@ -1744,6 +1751,24 @@ class Device(DeviceBase):
1744
1751
  play_oneip_sdp().'''
1745
1752
  return self._send_sink_sdp_command(SDP_REQUEST, 0)
1746
1753
 
1754
+ @property
1755
+ @override
1756
+ def oneip_privacy(self) -> OneIPPrivacy|None:
1757
+ return self._v2ip_privacy
1758
+
1759
+ async def request_oneip_privacy(self) -> bool:
1760
+ '''Ask this OneIP source unit how its streams are encrypted now, rather
1761
+ than at its next report, which comes at least once a minute and whenever
1762
+ they change. Refused for a device that is not a OneIP source, and for
1763
+ one below protocol 0x30, which predates the report.'''
1764
+ if (self.features is None) or (DeviceFeature.ONEIP_SOURCE not in self.features):
1765
+ _LOGGER.warning(f"not asking {self} how its streams are encrypted: it is not a OneIP source")
1766
+ return False
1767
+ frame = FrameV2IPPrivacy.construct_request(mxr=self.registry, target=self, target_uid=self.remote_id)
1768
+ if (frame is None):
1769
+ return False
1770
+ return self.registry.transmit(frame.frame) == len(frame.frame)
1771
+
1747
1772
  def _send_sink_sdp_command(self, kind:int, value:int) -> bool:
1748
1773
  '''The one send behind the session description commands.'''
1749
1774
  if not self.is_oneip_sink:
@@ -1,4 +1,4 @@
1
- from ..Interface import AmpDolbySettings as AmpDolbySettings, AudioChangeSource as AudioChangeSource, AudioEndpoint as AudioEndpoint, AudioEndpoints as AudioEndpoints, AudioLinks as AudioLinks, BayBase as BayBase, DeviceBase as DeviceBase, DeviceClock as DeviceClock, DeviceOneIPDetails as DeviceOneIPDetails, DeviceOneIPScalingSettings as DeviceOneIPScalingSettings, DeviceOneIPSink as DeviceOneIPSink, DeviceRegistry as DeviceRegistry, DeviceStatus as DeviceStatus, Multiviewer as Multiviewer, MxrCallbacks as MxrCallbacks, OneIPDeviceSettings as OneIPDeviceSettings, OneIPOutputMode as OneIPOutputMode, OneIPPowerSaveSchedule as OneIPPowerSaveSchedule, OneIPPtpMessages as OneIPPtpMessages, OneIPPtpState as OneIPPtpState, OneIPScalingSettings as OneIPScalingSettings, OneIPSinkSdp as OneIPSinkSdp, OneIPStreamSources as OneIPStreamSources, OneIPStreamSourcesList as OneIPStreamSourcesList, OneIPTestSync as OneIPTestSync, OneIPTestTone as OneIPTestTone, OneIPTestcard as OneIPTestcard, OneIPVlan as OneIPVlan, OsdSync as OsdSync, OsdSyncReport as OsdSyncReport, SystemTemperature as SystemTemperature, TimeZone as TimeZone, UnitNetConfig as UnitNetConfig, UnitStatus as UnitStatus
1
+ from ..Interface import AmpDolbySettings as AmpDolbySettings, AudioChangeSource as AudioChangeSource, AudioEndpoint as AudioEndpoint, AudioEndpoints as AudioEndpoints, AudioLinks as AudioLinks, BayBase as BayBase, DeviceBase as DeviceBase, DeviceClock as DeviceClock, DeviceOneIPDetails as DeviceOneIPDetails, DeviceOneIPScalingSettings as DeviceOneIPScalingSettings, DeviceOneIPSink as DeviceOneIPSink, DeviceRegistry as DeviceRegistry, DeviceStatus as DeviceStatus, Multiviewer as Multiviewer, MxrCallbacks as MxrCallbacks, OneIPDeviceSettings as OneIPDeviceSettings, OneIPOutputMode as OneIPOutputMode, OneIPPowerSaveSchedule as OneIPPowerSaveSchedule, OneIPPrivacy as OneIPPrivacy, OneIPPtpMessages as OneIPPtpMessages, OneIPPtpState as OneIPPtpState, OneIPScalingSettings as OneIPScalingSettings, OneIPSinkSdp as OneIPSinkSdp, OneIPStreamSources as OneIPStreamSources, OneIPStreamSourcesList as OneIPStreamSourcesList, OneIPTestSync as OneIPTestSync, OneIPTestTone as OneIPTestTone, OneIPTestcard as OneIPTestcard, OneIPVlan as OneIPVlan, OsdSync as OsdSync, OsdSyncReport as OsdSyncReport, SystemTemperature as SystemTemperature, TimeZone as TimeZone, UnitNetConfig as UnitNetConfig, UnitStatus as UnitStatus
2
2
  from ..Uid import MxrDeviceUid as MxrDeviceUid
3
3
  from ..compat import override as override
4
4
  from ..const import MXR_CONFIG_TIMEOUT as MXR_CONFIG_TIMEOUT
@@ -18,6 +18,7 @@ from ..proto.FrameV2IPAudio import AUDIO_LAYOUT_SINCE as AUDIO_LAYOUT_SINCE, Fra
18
18
  from ..proto.FrameV2IPBayMapping import FrameV2IPBayMapping as FrameV2IPBayMapping
19
19
  from ..proto.FrameV2IPDeviceConfiguration import FrameV2IPDeviceConfiguration as FrameV2IPDeviceConfiguration
20
20
  from ..proto.FrameV2IPMultiviewer import FrameV2IPMultiviewer as FrameV2IPMultiviewer, V2IPMultiviewerConfig as V2IPMultiviewerConfig
21
+ from ..proto.FrameV2IPPrivacy import FrameV2IPPrivacy as FrameV2IPPrivacy
21
22
  from ..proto.FrameV2IPSinkSdp import FrameV2IPSinkSdp as FrameV2IPSinkSdp, SDP_REQUEST as SDP_REQUEST, SDP_SCREEN as SDP_SCREEN, SDP_STOP as SDP_STOP
22
23
  from ..proto.FrameV2IPStats import FrameV2IPStats as FrameV2IPStats
23
24
  from ..proto.FrameV2IPTestcard import FrameV2IPTestcard as FrameV2IPTestcard, TESTCARD_PART_PATTERN as TESTCARD_PART_PATTERN, TESTCARD_PART_SYNC as TESTCARD_PART_SYNC, TESTCARD_PART_TONE as TESTCARD_PART_TONE, TESTCARD_REQUEST as TESTCARD_REQUEST, TESTCARD_SET as TESTCARD_SET
@@ -286,6 +287,10 @@ class Device(DeviceBase):
286
287
  async def stop_oneip_sdp(self) -> bool: ...
287
288
  async def set_oneip_sdp_screen(self, screen: int) -> bool: ...
288
289
  async def request_oneip_sink_sdp(self) -> bool: ...
290
+ @property
291
+ @override
292
+ def oneip_privacy(self) -> OneIPPrivacy | None: ...
293
+ async def request_oneip_privacy(self) -> bool: ...
289
294
  def __eq__(self, other: Any) -> bool: ...
290
295
 
291
296
  class MultiviewerImpl(Multiviewer):
@@ -31,6 +31,7 @@ from ..proto.FramePing import FramePing
31
31
  from ..proto.FrameBase import FrameBase
32
32
  from ..proto.FrameTime import FrameTime
33
33
  from ..proto.FrameTimeZone import FrameTimeZone
34
+ from ..proto.FrameV2IPPrivacy import FrameV2IPPrivacy
34
35
  from ..proto.FrameV2IPSettingsAll import FrameV2IPSettingsAll
35
36
  from ..proto.FrameV2IPOsdSync import FrameV2IPOsdSync
36
37
  from .OsdSync import LogoServer, OsdSender, PASS_GAP, osd_content_refusal
@@ -529,6 +530,12 @@ class Remote(DeviceRegistry, ConnectionCallbacks):
529
530
  return False
530
531
  return self._send_to_all(FrameV2IPSettingsAll.construct(mxr=self, settings=settings))
531
532
 
533
+ async def request_all_oneip_privacy(self) -> bool:
534
+ '''Ask every OneIP source unit of the mesh how its streams are
535
+ encrypted, as Device.request_oneip_privacy() asks one. A unit below
536
+ protocol 0x30 ignores it.'''
537
+ return self._send_to_all(FrameV2IPPrivacy.construct_request(mxr=self, target=None, target_uid=None))
538
+
532
539
  async def set_mesh_time_zone(self, zone:str, rule:str) -> bool:
533
540
  '''Set the time zone of every device that hears it.
534
541
 
@@ -692,7 +699,10 @@ class Remote(DeviceRegistry, ConnectionCallbacks):
692
699
  it announces itself as the mesh's overlay sync manager, so a unit waking
693
700
  from power save takes this content too, rather than the controller's.
694
701
  The controller can still send its own over it; the last sync a unit
695
- took is what it shows. Each unit's osd_sync_report says how it went.
702
+ took is what it shows. It does so whenever a unit asks for settings
703
+ without naming whose sync they are from, as an older overlay module
704
+ does, and nothing this client sends prevents that. Each unit's
705
+ osd_sync_report says how it went.
696
706
 
697
707
  Refused for content a unit would refuse: copying nothing, more than
698
708
  OSD_SYNC_RECORDS_MAX settings, a caption font over OSD_SYNC_FONT_MAX or
@@ -12,6 +12,7 @@ from ..proto.FramePing import FramePing as FramePing
12
12
  from ..proto.FrameTime import FrameTime as FrameTime
13
13
  from ..proto.FrameTimeZone import FrameTimeZone as FrameTimeZone
14
14
  from ..proto.FrameV2IPOsdSync import FrameV2IPOsdSync as FrameV2IPOsdSync
15
+ from ..proto.FrameV2IPPrivacy import FrameV2IPPrivacy as FrameV2IPPrivacy
15
16
  from ..proto.FrameV2IPSettingsAll import FrameV2IPSettingsAll as FrameV2IPSettingsAll
16
17
  from ..proto.Svd import SvdMap as SvdMap
17
18
  from .ConnectionAsync import ConnectionAsync as ConnectionAsync
@@ -72,6 +73,7 @@ class Remote(DeviceRegistry, ConnectionCallbacks):
72
73
  def transmit(self, data: bytes) -> int: ...
73
74
  def tx_discover(self) -> int: ...
74
75
  async def set_all_oneip_device_settings(self, settings: OneIPDeviceSettings) -> bool: ...
76
+ async def request_all_oneip_privacy(self) -> bool: ...
75
77
  async def set_mesh_time_zone(self, zone: str, rule: str) -> bool: ...
76
78
  async def clear_mesh_time_zone(self) -> bool: ...
77
79
  async def set_mesh_time(self, when: datetime | None = None) -> bool: ...
@@ -81,6 +81,7 @@ BASELINE = {
81
81
  'FrameV2IPManualSourceSwitch.FrameV2IPManualSourceSwitch',
82
82
  'FrameV2IPMultiviewer.FrameV2IPMultiviewer',
83
83
  'FrameV2IPPowerSave.FrameV2IPPowerSave',
84
+ 'FrameV2IPPrivacy.FrameV2IPPrivacy',
84
85
  'FrameV2IPSinkSdp.FrameV2IPSinkSdp',
85
86
  'FrameV2IPSourceSwitch.FrameV2IPSourceSwitch',
86
87
  'FrameV2IPOsdSync.FrameV2IPOsdSync',
@@ -0,0 +1,112 @@
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
+ '''How a OneIP source unit encrypts its streams (0x54), and asking it.
8
+
9
+ Every field of every stream is distinct from the others and from stream to
10
+ stream, with a port and a key version too wide for a narrower read and the
11
+ reserved bytes set, so a field read at a neighbour's offset shows.
12
+ '''
13
+
14
+ import asyncio, os, struct, sys, logging
15
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
16
+ logging.disable(logging.CRITICAL)
17
+ import mx_remote
18
+ from mx_remote import DeviceFeature, MxrDeviceUid, OneIPPrivacyStream
19
+ from mx_remote.proto.Factory import create_mxr_frame
20
+
21
+ ADDR = ('192.0.2.9', 8812)
22
+ mx = mx_remote.Remote(open_connection=False)
23
+ mx._uid = bytes(range(0x20, 0x30))
24
+ sent = []
25
+ mx.transmit = lambda data: (sent.append(bytes(data)), len(data))[1]
26
+
27
+ def uid(n):
28
+ return bytes([n]) * 16
29
+
30
+ def name(s, sz=16):
31
+ b = s.encode('ascii')[:sz]
32
+ return b + bytes(sz - len(b))
33
+
34
+ def rx(sender, opcode, payload=b'', protocol=0x30):
35
+ frame = bytearray(create_mxr_frame(sender, opcode, payload))
36
+ frame[2] = protocol
37
+ mx.process_frame(0.0, bytes(frame), ADDR)
38
+
39
+ def hello(sender, protocol, features):
40
+ rx(sender, 0x00, struct.pack('<H', protocol) + name('ONEIP') + name('P8SN%08d' % sender[0])
41
+ + name('5.0.0') + struct.pack('<I', int(features)), protocol=0x01)
42
+ return mx.get_by_uid(MxrDeviceUid(sender))
43
+
44
+ def call(coro):
45
+ return asyncio.run(coro)
46
+
47
+ def stream(n):
48
+ '''Stream n; stream 2 is in the clear.'''
49
+ s = bytes([239, 0, 0, n]) + struct.pack('<H', 5000 + n * 0x101) + bytes([0x10 + n, 0xEE])
50
+ s += bytes([int(n != 2), 0x20 + n, 0x30 + n])
51
+ s += bytes(0x40 + n * 16 + i for i in range(8))
52
+ s += bytes(0x80 + n * 16 + i for i in range(16))
53
+ s += bytes(0xC0 + n * 8 + i for i in range(8))
54
+ s += struct.pack('<I', 0x01020304 * n) + bytes([0xEE])
55
+ assert len(s) == 48, len(s)
56
+ return s
57
+
58
+ def expected(n):
59
+ return OneIPPrivacyStream(group=f'239.0.0.{n}', port=5000 + n * 0x101, version=0x10 + n, on=(n != 2),
60
+ full_id=0x20 + n, short_id=0x30 + n,
61
+ iv=bytes(0x40 + n * 16 + i for i in range(8)),
62
+ key_generator=bytes(0x80 + n * 16 + i for i in range(16)),
63
+ key_id=bytes(0xC0 + n * 8 + i for i in range(8)),
64
+ key_version=0x01020304 * n)
65
+
66
+ def report(header_uid, count=3):
67
+ return header_uid + bytes([0, count, 0xA5, 0x5A]) + b''.join(stream(n) for n in range(1, count + 1))
68
+
69
+ # ------------------------------------------------------------------- reading
70
+ # A report is filed under its sender, whatever uid its header names.
71
+ src = hello(uid(0x10), 0x30, DeviceFeature.ONEIP_SOURCE)
72
+ assert src.oneip_privacy is None
73
+ rx(uid(0x10), 0x54, report(uid(0x11)))
74
+ p = src.oneip_privacy
75
+ assert p is not None, 'no report was kept'
76
+ assert (p.video, p.audio, p.anc) == (expected(1), expected(2), expected(3)), p
77
+ assert mx.get_by_uid(MxrDeviceUid(uid(0x11))) is None
78
+ print('decode :', p)
79
+
80
+ # Only a whole report of three streams is kept; a longer one is read for its
81
+ # first three.
82
+ other = hello(uid(0x12), 0x30, DeviceFeature.ONEIP_SOURCE)
83
+ whole = report(uid(0x12))
84
+ request = whole[:16] + bytes([1]) + whole[17:]
85
+ two = whole[:17] + bytes([2]) + whole[18:]
86
+ for what, frame in (('a request', request), ('a count of two', two), ('a frame a byte short', whole[:-1])):
87
+ rx(uid(0x12), 0x54, frame)
88
+ assert other.oneip_privacy is None, f'{what} was kept'
89
+ rx(uid(0x12), 0x54, report(uid(0x12), count=4))
90
+ assert other.oneip_privacy is not None and other.oneip_privacy.anc == expected(3), 'a longer report was dropped'
91
+ print('whole : only a report of all three streams is kept')
92
+
93
+ # ------------------------------------------------------------------- asking
94
+ sent.clear()
95
+ assert call(src.request_oneip_privacy()) is True
96
+ frame = sent[-1]
97
+ assert int.from_bytes(frame[20:22], 'little') == 0x54 and frame[2] == 0x30, frame[:24].hex()
98
+ assert frame[24:] == uid(0x10) + bytes([1, 0, 0, 0]), frame[24:].hex()
99
+ sent.clear()
100
+ assert call(mx.request_all_oneip_privacy()) is True
101
+ assert sent[-1][24:] == bytes(16) + bytes([1, 0, 0, 0]) and sent[-1][2] == 0x30, sent[-1].hex()
102
+ print('request : names the unit asked, or the zero uid for every one, stamped 0x30')
103
+
104
+ def refused(coro, why):
105
+ n = len(sent)
106
+ assert call(coro) is False, why
107
+ assert len(sent) == n, f'{why}: refused, but transmitted anyway'
108
+ refused(hello(uid(0x13), 0x2F, DeviceFeature.ONEIP_SOURCE).request_oneip_privacy(), 'a source below 0x30')
109
+ refused(hello(uid(0x14), 0x30, DeviceFeature.ONEIP_SINK).request_oneip_privacy(), 'a unit that is not a source')
110
+ print('refusals : nothing a unit would ignore is sent')
111
+
112
+ print('ALL OK')
@@ -40,6 +40,7 @@ ABOVE_CAP = {
40
40
  0x50: 'SYS_UNIT_COMMAND',
41
41
  0x52: 'V2IP_SINK_SDP',
42
42
  0x53: 'V2IP_OSD_SYNC',
43
+ 0x54: 'V2IP_PRIVACY',
43
44
  }
44
45
  root = pathlib.Path(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'mx_remote', 'proto'))
45
46
  # Calls only. The pattern also matches the def in FrameBase, which carries a
@@ -58,6 +58,7 @@ SUITES = [
58
58
  'routes', # what a OneIP output is routed to, as the frames a unit acts on move it
59
59
  'meshbays', # the bays a unit lists for other units, from its mappings
60
60
  'v2ipsdp', # what a OneIP output plays from a session description
61
+ 'privacy', # how a OneIP source unit encrypts its streams
61
62
  'baycontent', # what the source on an input is showing
62
63
  'osdsync', # overlay sync, watched and sent
63
64
  'deprecated', # the V2IP names 5.11.0 exported still resolve, with a warning
@@ -11,6 +11,7 @@ print('version', mx_remote.const.VERSION, 'proto', hex(MXR_PROTOCOL_VERSION))
11
11
  # handles is stamped at.
12
12
  assert MXR_PROTOCOL_VERSION == max(MXR_OPCODE_VERSIONS.values()), hex(MXR_PROTOCOL_VERSION)
13
13
  assert MXR_OPCODE_VERSIONS[0x52] == 0x2E and MXR_OPCODE_VERSIONS[0x53] == 0x2F # V2IP_SINK_SDP, V2IP_OSD_SYNC
14
+ assert MXR_OPCODE_VERSIONS[0x54] == 0x30 # V2IP_PRIVACY
14
15
  assert MXR_OPCODE_VERSIONS[0x49] == 0x28 and MXR_OPCODE_VERSIONS[0x3C] == 0x11
15
16
  assert MXR_OPCODE_VERSIONS[0x4F] == 0x2C and MXR_OPCODE_VERSIONS[0x50] == 0x2C # SYS_UNIT_STATUS, SYS_UNIT_COMMAND
16
17
  assert DeviceFeature.VIDEO_WALL == (1 << 24)