mx-remote 4.7.8__tar.gz → 5.0.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 (207) hide show
  1. {mx_remote-4.7.8 → mx_remote-5.0.0}/CLAUDE.md +47 -4
  2. {mx_remote-4.7.8 → mx_remote-5.0.0}/PKG-INFO +60 -17
  3. {mx_remote-4.7.8 → mx_remote-5.0.0}/README.md +58 -15
  4. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/Interface.py +242 -4
  5. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/Interface.pyi +41 -1
  6. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/const.py +10 -1
  7. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/const.pyi +3 -0
  8. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/BayConfig.py +25 -5
  9. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/BayConfig.pyi +3 -1
  10. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/Constants.py +251 -10
  11. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/Constants.pyi +61 -1
  12. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/Factory.py +30 -0
  13. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameAmpZoneSettings.py +52 -4
  14. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameAmpZoneSettings.pyi +2 -0
  15. mx_remote-5.0.0/mx_remote/proto/FrameAudioClip.py +44 -0
  16. mx_remote-4.7.8/mx_remote/proto/FrameRCIr.pyi → mx_remote-5.0.0/mx_remote/proto/FrameAudioClip.pyi +3 -2
  17. mx_remote-5.0.0/mx_remote/proto/FrameAudioSetRoute.py +55 -0
  18. mx_remote-5.0.0/mx_remote/proto/FrameAudioSetRoute.pyi +14 -0
  19. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameBase.py +31 -6
  20. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameBase.pyi +4 -2
  21. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameBayConfig.py +17 -10
  22. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameBayConfigSecondary.py +13 -10
  23. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameBayStatus.py +3 -3
  24. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameBayStatus.pyi +1 -1
  25. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameFirmwareVersion.py +13 -8
  26. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameFirmwareVersion.pyi +2 -2
  27. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameHeader.py +4 -1
  28. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameLinks.py +17 -13
  29. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameMeshOperation.py +2 -1
  30. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameMeshOperation.pyi +1 -0
  31. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameNetworkStatus.py +5 -4
  32. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameNetworkStatus.pyi +1 -0
  33. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameRCAction.py +2 -5
  34. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameRCAction.pyi +1 -1
  35. mx_remote-5.0.0/mx_remote/proto/FrameRCIr.py +85 -0
  36. mx_remote-5.0.0/mx_remote/proto/FrameRCIr.pyi +23 -0
  37. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameRCKey.py +2 -4
  38. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameRCKey.pyi +1 -1
  39. mx_remote-5.0.0/mx_remote/proto/FrameRCSettings.py +150 -0
  40. mx_remote-5.0.0/mx_remote/proto/FrameRCSettings.pyi +28 -0
  41. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameRoutingChange.py +5 -5
  42. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameSetRoute.py +11 -8
  43. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameSetupStatus.py +1 -1
  44. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameSignalStatusNew.py +58 -8
  45. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameSignalStatusNew.pyi +8 -1
  46. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameTXRCAction.py +28 -10
  47. mx_remote-5.0.0/mx_remote/proto/FrameTXRCKey.py +71 -0
  48. mx_remote-5.0.0/mx_remote/proto/FrameTXRCKey.pyi +18 -0
  49. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameTxIR.py +15 -6
  50. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameV2IPAudio.py +21 -4
  51. mx_remote-5.0.0/mx_remote/proto/FrameV2IPDetectBays.py +30 -0
  52. mx_remote-5.0.0/mx_remote/proto/FrameV2IPDetectBays.pyi +7 -0
  53. mx_remote-5.0.0/mx_remote/proto/FrameV2IPDeviceConfiguration.py +168 -0
  54. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameV2IPDeviceConfiguration.pyi +7 -2
  55. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameV2IPMultiviewer.py +46 -15
  56. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameV2IPMultiviewer.pyi +6 -0
  57. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameV2IPSources.py +11 -1
  58. mx_remote-5.0.0/mx_remote/proto/FrameV2IPVideoWall.py +155 -0
  59. mx_remote-5.0.0/mx_remote/proto/FrameV2IPVideoWall.pyi +32 -0
  60. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/LinkConfig.py +2 -2
  61. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/V2IPConfig.py +3 -1
  62. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/V2IPStats.py +43 -4
  63. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/V2IPStats.pyi +4 -0
  64. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/remote/Bay.py +56 -22
  65. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/remote/Bay.pyi +2 -1
  66. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/remote/ConnectionAsync.py +42 -0
  67. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/remote/Device.py +57 -24
  68. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/remote/Device.pyi +6 -0
  69. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/remote/Remote.py +13 -2
  70. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/remote/Remote.pyi +1 -1
  71. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/remote/V2IP.py +2 -0
  72. {mx_remote-4.7.8 → mx_remote-5.0.0}/pyproject.toml +13 -0
  73. mx_remote-5.0.0/tests/audio_ir.py +41 -0
  74. mx_remote-5.0.0/tests/capture.py +51 -0
  75. mx_remote-5.0.0/tests/e2e.py +108 -0
  76. mx_remote-5.0.0/tests/handlers.py +124 -0
  77. mx_remote-5.0.0/tests/merge.py +112 -0
  78. mx_remote-5.0.0/tests/newops.py +102 -0
  79. mx_remote-5.0.0/tests/offsets.py +59 -0
  80. mx_remote-5.0.0/tests/proto_stamp.py +60 -0
  81. mx_remote-5.0.0/tests/rcsettings.py +79 -0
  82. mx_remote-5.0.0/tests/run.py +57 -0
  83. mx_remote-5.0.0/tests/smoke.py +45 -0
  84. mx_remote-5.0.0/tests/sources.py +74 -0
  85. mx_remote-5.0.0/tests/stats.py +73 -0
  86. mx_remote-5.0.0/tests/structs.py +93 -0
  87. mx_remote-5.0.0/tests/txresult.py +105 -0
  88. mx_remote-5.0.0/tests/unknowns.py +58 -0
  89. mx_remote-4.7.8/.claude/agents/semble-search.md +0 -30
  90. mx_remote-4.7.8/.claude/settings.local.json +0 -9
  91. mx_remote-4.7.8/mx_remote/proto/FrameRCIr.py +0 -29
  92. mx_remote-4.7.8/mx_remote/proto/FrameRCSettings.py +0 -18
  93. mx_remote-4.7.8/mx_remote/proto/FrameRCSettings.pyi +0 -4
  94. mx_remote-4.7.8/mx_remote/proto/FrameV2IPDeviceConfiguration.py +0 -108
  95. {mx_remote-4.7.8 → mx_remote-5.0.0}/.gitignore +0 -0
  96. {mx_remote-4.7.8 → mx_remote-5.0.0}/LICENSE +0 -0
  97. {mx_remote-4.7.8 → mx_remote-5.0.0}/hatch_build.py +0 -0
  98. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/Uid.py +0 -0
  99. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/Uid.pyi +0 -0
  100. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/__init__.py +0 -0
  101. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/__init__.pyi +0 -0
  102. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/main.py +0 -0
  103. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/main.pyi +0 -0
  104. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/Data.py +0 -0
  105. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/Data.pyi +0 -0
  106. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/Factory.pyi +0 -0
  107. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameAmpDolbySettings.py +0 -0
  108. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameAmpDolbySettings.pyi +0 -0
  109. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameBayConfig.pyi +0 -0
  110. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameBayConfigSecondary.pyi +0 -0
  111. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameBayHide.py +0 -0
  112. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameBayHide.pyi +0 -0
  113. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameConnectStatus.py +0 -0
  114. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameConnectStatus.pyi +0 -0
  115. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameDebug.py +0 -0
  116. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameDebug.pyi +0 -0
  117. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameDiscover.py +0 -0
  118. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameDiscover.pyi +0 -0
  119. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameEDID.py +0 -0
  120. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameEDID.pyi +0 -0
  121. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameEDIDProfile.py +0 -0
  122. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameEDIDProfile.pyi +0 -0
  123. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameFactoryReset.py +0 -0
  124. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameFactoryReset.pyi +0 -0
  125. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameFilterStatus.py +0 -0
  126. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameFilterStatus.pyi +0 -0
  127. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameHeader.pyi +0 -0
  128. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameHello.py +0 -0
  129. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameHello.pyi +0 -0
  130. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameLinks.pyi +0 -0
  131. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameMirrorStatus.py +0 -0
  132. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameMirrorStatus.pyi +0 -0
  133. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameMonitoringPulse.py +0 -0
  134. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameMonitoringPulse.pyi +0 -0
  135. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FramePDUState.py +0 -0
  136. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FramePDUState.pyi +0 -0
  137. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FramePowerChange.py +0 -0
  138. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FramePowerChange.pyi +0 -0
  139. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameReboot.py +0 -0
  140. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameReboot.pyi +0 -0
  141. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameRoutingChange.pyi +0 -0
  142. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameSetInstaller.py +0 -0
  143. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameSetInstaller.pyi +0 -0
  144. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameSetName.py +0 -0
  145. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameSetName.pyi +0 -0
  146. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameSetRoute.pyi +0 -0
  147. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameSetupStatus.pyi +0 -0
  148. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameSignalStatus.py +0 -0
  149. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameSignalStatus.pyi +0 -0
  150. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameSysTemperature.py +0 -0
  151. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameSysTemperature.pyi +0 -0
  152. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameSystemStatus.py +0 -0
  153. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameSystemStatus.pyi +0 -0
  154. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameTXRCAction.pyi +0 -0
  155. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameTopology.py +0 -0
  156. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameTopology.pyi +0 -0
  157. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameTxIR.pyi +0 -0
  158. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameUpgradeFPGA.py +0 -0
  159. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameUpgradeFPGA.pyi +0 -0
  160. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameV2IPAudio.pyi +0 -0
  161. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameV2IPBayMapping.py +0 -0
  162. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameV2IPBayMapping.pyi +0 -0
  163. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameV2IPLink.py +0 -0
  164. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameV2IPLink.pyi +0 -0
  165. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameV2IPManualSourceSwitch.py +0 -0
  166. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameV2IPManualSourceSwitch.pyi +0 -0
  167. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameV2IPPowerSave.py +0 -0
  168. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameV2IPPowerSave.pyi +0 -0
  169. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameV2IPSetMaster.py +0 -0
  170. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameV2IPSetMaster.pyi +0 -0
  171. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameV2IPSourceSwitch.py +0 -0
  172. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameV2IPSourceSwitch.pyi +0 -0
  173. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameV2IPSources.pyi +0 -0
  174. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameV2IPStats.py +0 -0
  175. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameV2IPStats.pyi +0 -0
  176. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameV2IPStreamDetails.py +0 -0
  177. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameV2IPStreamDetails.pyi +0 -0
  178. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameV2IPTiling.py +0 -0
  179. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameV2IPTiling.pyi +0 -0
  180. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameVolume.py +0 -0
  181. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameVolume.pyi +0 -0
  182. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameVolumeDown.py +0 -0
  183. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameVolumeDown.pyi +0 -0
  184. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameVolumeSet.py +0 -0
  185. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameVolumeSet.pyi +0 -0
  186. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameVolumeUp.py +0 -0
  187. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/FrameVolumeUp.pyi +0 -0
  188. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/LinkConfig.pyi +0 -0
  189. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/Multiviewer.py +0 -0
  190. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/Multiviewer.pyi +0 -0
  191. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/PDUState.py +0 -0
  192. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/PDUState.pyi +0 -0
  193. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/Svd.py +0 -0
  194. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/Svd.pyi +0 -0
  195. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/V2IPConfig.pyi +0 -0
  196. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/__init__.py +0 -0
  197. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/__init__.pyi +0 -0
  198. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/proto/svd.csv +0 -0
  199. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/py.typed +0 -0
  200. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/remote/ConnectionAsync.pyi +0 -0
  201. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/remote/Link.py +0 -0
  202. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/remote/Link.pyi +0 -0
  203. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/remote/P8PDU.py +0 -0
  204. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/remote/P8PDU.pyi +0 -0
  205. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/remote/State.py +0 -0
  206. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/remote/State.pyi +0 -0
  207. {mx_remote-4.7.8 → mx_remote-5.0.0}/mx_remote/remote/V2IP.pyi +0 -0
@@ -21,10 +21,44 @@ mxr # console app: discover devices, log frames live
21
21
  mxr -l <local_ip> # bind to a specific interface
22
22
  mxr -b # broadcast mode instead of multicast
23
23
  mxr -i <capture> [-f <ip>] # parse a MatrixOS/Wireshark capture file offline
24
+ python tests/run.py # run every protocol test
25
+ python tests/run.py e2e stats # run named suites only
24
26
  ```
25
27
 
26
- There is **no test suite** and no lint config in this repo. Validate protocol changes by
27
- running `mxr` against live hardware or by replaying a capture file with `mxr -i`.
28
+ There is no lint config in this repo.
29
+
30
+ `tests/` holds standalone scripts with no test framework behind it — each builds
31
+ frames, feeds them through `Remote.process_frame`, and asserts what was decoded.
32
+ A suite prints what it decoded and ends with `ALL OK`, so it can be run on its
33
+ own as well as through `run.py`.
34
+
35
+ Two things they pin that code review does not catch:
36
+
37
+ - **Struct sizes, not just field offsets.** The V2IP stats blocks are 20 and 44
38
+ bytes only because the firmware's `ALIGN(8)` sits where GCC ignores it. Writing
39
+ that declaration the other way round shifts every block after the first, and a
40
+ wrong split still totals 128.
41
+ - **Field widths.** Padding in the fixtures is filled with recognisable bytes, so
42
+ a field read at the right offset with the wrong width returns a wrong value. A
43
+ zero-filled fixture cannot catch that: the widened read returns the same
44
+ answer.
45
+ - **Which handlers run at all.** `tests/handlers.py` reports how many frame
46
+ `process()` methods the other suites execute and fails if one loses its
47
+ coverage. A handler nothing calls is invisible to every other check here,
48
+ since they all measure tests that run.
49
+
50
+ `tests/capture.py` holds real frames off a live mesh, with the device uids
51
+ replaced. Its expected values come from firmware behaviour rather than from this
52
+ decoder, which is the property a synthetic fixture cannot have — assert against
53
+ something external, or a fixture only proves the decoder agrees with itself.
54
+
55
+ Validate wire changes against live hardware as well, by running `mxr` or
56
+ replaying a capture file with `mxr -i`.
57
+
58
+ A client's uid is persisted at `~/.mxr-uid`, one file per account, and
59
+ `process_frame` drops any frame carrying our own uid. Two clients run from one
60
+ account are a single identity to the mesh and each discards everything the other
61
+ sends, so a second one needs its own `uid_path`.
28
62
 
29
63
  ### Versioning
30
64
  Version is single-sourced in `mx_remote/const.py` (`VERSION = '...'`); `pyproject.toml`
@@ -67,8 +101,17 @@ Three layers, decoupled by abstract base classes:
67
101
  `remote_id == self.uid` (our own echoes) are skipped.
68
102
 
69
103
  ### Frame wire format
70
- `[0x50, 0x38, protocol, 0x00]` ("P8" magic + protocol version) + opcode (u16 LE) +
71
- length (u16 LE) + payload. Built by `proto/Factory.py::create_mxr_frame`.
104
+ A 24-byte header, then the payload:
105
+
106
+ ```
107
+ 0 [0x50, 0x38, protocol, 0x00] "P8" magic + protocol version
108
+ 4 sender uid 16 bytes, the device that sent this frame
109
+ 20 opcode u16 LE
110
+ 22 payload length u16 LE
111
+ 24 payload
112
+ ```
113
+
114
+ Built by `proto/Factory.py::create_mxr_frame`, decoded by `proto/FrameHeader.py`.
72
115
 
73
116
  ## Working on the protocol
74
117
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: mx_remote
3
- Version: 4.7.8
3
+ Version: 5.0.0
4
4
  Summary: Python 3 library for interfacing with MX Remote compatible devices
5
5
  Project-URL: Homepage, https://github.com/opdenkamp/mx-remote/
6
6
  Project-URL: Documentation, https://github.com/opdenkamp/mx-remote/
@@ -30,14 +30,39 @@ Requires-Dist: aiohttp>=3.10.5
30
30
  Requires-Dist: netifaces>=0.11.0
31
31
  Description-Content-Type: text/markdown
32
32
 
33
- # MX Remote Interface
33
+ # MX Remote — Pulse-Eight device interface
34
34
 
35
- Python 3 library for interfacing with [MX Remote](https://github.com/opdenkamp/mx-remote/) compatible devices over a local network. Supports device discovery, video/audio routing, volume control, remote control key passthrough, V2IP (OneIP) streaming, and more.
35
+ Python 3 library for discovering and controlling [Pulse-Eight](https://www.pulse-eight.com/)
36
+ AV distribution hardware over a local network: video/audio matrices, HDMI-over-IP
37
+ encoders/decoders, multiviewers, and audio amplifiers. Supports device discovery,
38
+ video/audio routing, volume control, remote-control key passthrough, HDMI-over-IP
39
+ streaming, multiviewer control, and more.
40
+
41
+ If you are looking to integrate Pulse-Eight **neo**, **OneIP**, or **ProAmp8** devices into
42
+ your own software or home-automation system, this is the library for it.
43
+
44
+ ## What is MX Remote?
45
+
46
+ MX Remote is the network protocol these Pulse-Eight devices use to discover and control one
47
+ another over UDP (multicast or broadcast). All of them run the shared **MatrixOS** firmware,
48
+ which speaks this protocol natively. This library is a client implementation of that
49
+ protocol — its purpose is to expose the devices to third-party software.
50
+
51
+ ## Supported devices
52
+
53
+ All devices below run MatrixOS and are controlled through the same protocol:
54
+
55
+ - **[Pulse-Eight neo](https://www.pulse-eight.com/)** — HDBaseT video/audio matrices
56
+ (neo:4, neo:8, neo:X, and splitters)
57
+ - **[Pulse-Eight OneIP](https://www.pulse-eight.com/p/248/oneip-tx)** — HDMI-over-IP
58
+ units: Transmitter (TX), Receiver (RX), Transceiver (TZ), and Multiviewer
59
+ - **[Pulse-Eight ProAmp8](https://www.pulse-eight.com/p/219/proamp-8)** — 8-zone audio amplifier with
60
+ Dolby support
36
61
 
37
62
  ## Requirements
38
63
 
39
64
  - Python 3.11 or later
40
- - Network access to MX Remote compatible devices (multicast or broadcast)
65
+ - Network access to one or more of the Pulse-Eight devices above (multicast or broadcast)
41
66
 
42
67
  ## Installation
43
68
 
@@ -91,11 +116,19 @@ mx = mx_remote.Remote(target_ip="10.8.8.255", port=8811)
91
116
 
92
117
  # offline mode for processing capture files
93
118
  mx = mx_remote.Remote(open_connection=False)
119
+
120
+ # separate identity, to run concurrent clients from one account
121
+ mx = mx_remote.Remote(uid_path="/var/lib/myapp/.mxr-uid")
94
122
  ```
95
123
 
124
+ The uid at `uid_path` (default `~/.mxr-uid`) is this client's identity on the mesh:
125
+ addressing is by uid in the payload, and a frame carrying our own uid is dropped as
126
+ an echo. Two clients sharing a uid are one peer to every device and each silently
127
+ discards everything the other sends, so give concurrent clients separate paths.
128
+
96
129
  ### Device
97
130
 
98
- A `DeviceBase` represents a physical device on the network (matrix, OneIP unit, amplifier). Devices are automatically registered when they respond to discovery requests.
131
+ A `DeviceBase` represents a physical device on the network (a Pulse-Eight neo matrix, OneIP unit, or ProAmp8 amplifier). Devices are automatically registered when they respond to discovery requests.
99
132
 
100
133
  ```python
101
134
  # look up a device by serial number or unique ID
@@ -111,17 +144,23 @@ device.version # firmware version
111
144
  device.online # True if responding
112
145
  device.status # DeviceStatus enum (ONLINE, OFFLINE, REBOOTING, BOOTING, INACTIVE)
113
146
  device.features # DeviceFeatures bitmask
147
+ device.config_initialised # False if the device broadcasts config blocks built from
148
+ # uninitialised memory; scaling, bay 0 addresses and the
149
+ # rc target padding are unreliable from it
114
150
  device.temperatures # dict of temperature sensor readings
115
151
 
116
152
  # device type checks
117
- device.is_v2ip # OneIP device
118
- device.is_video_matrix # video matrix
153
+ device.is_v2ip # Pulse-Eight OneIP HDMI-over-IP device
154
+ device.is_video_matrix # neo video matrix
119
155
  device.is_audio_matrix # audio-only matrix
120
- device.is_amp # audio amplifier
156
+ device.is_amp # ProAmp8 audio amplifier
121
157
  device.is_oneip_tx # OneIP transmitter
122
158
  device.is_oneip_rx # OneIP receiver
123
159
  device.is_oneip_tz # OneIP transceiver
124
160
  device.is_oneip_multiviewer # OneIP multiviewer
161
+ device.supports_video_wall # sink can crop its source to a video wall window
162
+ # (advertised ~1s after boot by the v2ipwall module,
163
+ # and never withdrawn once advertised)
125
164
 
126
165
  # iterate bays
127
166
  for port, bay in device.bays.items():
@@ -336,24 +375,28 @@ log = await device.get_log()
336
375
  result = await device.get_api("system/status")
337
376
  ```
338
377
 
339
- ## OneIP (V2IP) Devices
378
+ ## Pulse-Eight OneIP Devices
340
379
 
341
- OneIP devices expose additional streaming properties:
380
+ [Pulse-Eight OneIP](https://www.pulse-eight.com/p/248/oneip-tx) HDMI-over-IP devices expose additional streaming properties:
342
381
 
343
382
  ```python
344
- # V2IP stream source addresses
383
+ # stream source addresses
345
384
  if device.is_v2ip and device.v2ip_sources:
346
385
  for source in device.v2ip_sources:
347
386
  print(f"Video: {source.video.ip}:{source.video.port}")
348
387
  print(f"Audio: {source.audio.ip}:{source.audio.port}")
349
388
 
350
- # V2IP device details (encoder/decoder config)
389
+ # stream details (encoder/decoder config)
351
390
  if device.v2ip_details:
352
391
  details = device.v2ip_details
353
392
  print(f"Video: {details.video}")
354
- print(f"TX rate: {details.tx_rate}")
393
+ print(f"TX rate: {details.tx_rate}") # None when the sender offered no rate
394
+
395
+ # per-stream DSCP marking (None from peers that predate it)
396
+ if details.dscp and details.dscp.complete:
397
+ print(f"DSCP: {details.dscp}") # video / audio / anc, 0..63
355
398
 
356
- # V2IP statistics
399
+ # streaming statistics
357
400
  await device.read_stats(enable=True) # start collecting
358
401
  # ... later ...
359
402
  stats = device.v2ip_stats
@@ -368,9 +411,9 @@ if device.v2ip_firmware_versions:
368
411
  print(f"{fw_type}: {fw.version}")
369
412
  ```
370
413
 
371
- ## OneIP Multiviewer
414
+ ## Pulse-Eight OneIP Multiviewer
372
415
 
373
- Control multiviewer-specific settings:
416
+ Control [OneIP Multiviewer](https://www.pulse-eight.com/p/248/oneip-tx)-specific settings:
374
417
 
375
418
  ```python
376
419
  from mx_remote import (
@@ -1,11 +1,36 @@
1
- # MX Remote Interface
1
+ # MX Remote — Pulse-Eight device interface
2
2
 
3
- Python 3 library for interfacing with [MX Remote](https://github.com/opdenkamp/mx-remote/) compatible devices over a local network. Supports device discovery, video/audio routing, volume control, remote control key passthrough, V2IP (OneIP) streaming, and more.
3
+ Python 3 library for discovering and controlling [Pulse-Eight](https://www.pulse-eight.com/)
4
+ AV distribution hardware over a local network: video/audio matrices, HDMI-over-IP
5
+ encoders/decoders, multiviewers, and audio amplifiers. Supports device discovery,
6
+ video/audio routing, volume control, remote-control key passthrough, HDMI-over-IP
7
+ streaming, multiviewer control, and more.
8
+
9
+ If you are looking to integrate Pulse-Eight **neo**, **OneIP**, or **ProAmp8** devices into
10
+ your own software or home-automation system, this is the library for it.
11
+
12
+ ## What is MX Remote?
13
+
14
+ MX Remote is the network protocol these Pulse-Eight devices use to discover and control one
15
+ another over UDP (multicast or broadcast). All of them run the shared **MatrixOS** firmware,
16
+ which speaks this protocol natively. This library is a client implementation of that
17
+ protocol — its purpose is to expose the devices to third-party software.
18
+
19
+ ## Supported devices
20
+
21
+ All devices below run MatrixOS and are controlled through the same protocol:
22
+
23
+ - **[Pulse-Eight neo](https://www.pulse-eight.com/)** — HDBaseT video/audio matrices
24
+ (neo:4, neo:8, neo:X, and splitters)
25
+ - **[Pulse-Eight OneIP](https://www.pulse-eight.com/p/248/oneip-tx)** — HDMI-over-IP
26
+ units: Transmitter (TX), Receiver (RX), Transceiver (TZ), and Multiviewer
27
+ - **[Pulse-Eight ProAmp8](https://www.pulse-eight.com/p/219/proamp-8)** — 8-zone audio amplifier with
28
+ Dolby support
4
29
 
5
30
  ## Requirements
6
31
 
7
32
  - Python 3.11 or later
8
- - Network access to MX Remote compatible devices (multicast or broadcast)
33
+ - Network access to one or more of the Pulse-Eight devices above (multicast or broadcast)
9
34
 
10
35
  ## Installation
11
36
 
@@ -59,11 +84,19 @@ mx = mx_remote.Remote(target_ip="10.8.8.255", port=8811)
59
84
 
60
85
  # offline mode for processing capture files
61
86
  mx = mx_remote.Remote(open_connection=False)
87
+
88
+ # separate identity, to run concurrent clients from one account
89
+ mx = mx_remote.Remote(uid_path="/var/lib/myapp/.mxr-uid")
62
90
  ```
63
91
 
92
+ The uid at `uid_path` (default `~/.mxr-uid`) is this client's identity on the mesh:
93
+ addressing is by uid in the payload, and a frame carrying our own uid is dropped as
94
+ an echo. Two clients sharing a uid are one peer to every device and each silently
95
+ discards everything the other sends, so give concurrent clients separate paths.
96
+
64
97
  ### Device
65
98
 
66
- A `DeviceBase` represents a physical device on the network (matrix, OneIP unit, amplifier). Devices are automatically registered when they respond to discovery requests.
99
+ A `DeviceBase` represents a physical device on the network (a Pulse-Eight neo matrix, OneIP unit, or ProAmp8 amplifier). Devices are automatically registered when they respond to discovery requests.
67
100
 
68
101
  ```python
69
102
  # look up a device by serial number or unique ID
@@ -79,17 +112,23 @@ device.version # firmware version
79
112
  device.online # True if responding
80
113
  device.status # DeviceStatus enum (ONLINE, OFFLINE, REBOOTING, BOOTING, INACTIVE)
81
114
  device.features # DeviceFeatures bitmask
115
+ device.config_initialised # False if the device broadcasts config blocks built from
116
+ # uninitialised memory; scaling, bay 0 addresses and the
117
+ # rc target padding are unreliable from it
82
118
  device.temperatures # dict of temperature sensor readings
83
119
 
84
120
  # device type checks
85
- device.is_v2ip # OneIP device
86
- device.is_video_matrix # video matrix
121
+ device.is_v2ip # Pulse-Eight OneIP HDMI-over-IP device
122
+ device.is_video_matrix # neo video matrix
87
123
  device.is_audio_matrix # audio-only matrix
88
- device.is_amp # audio amplifier
124
+ device.is_amp # ProAmp8 audio amplifier
89
125
  device.is_oneip_tx # OneIP transmitter
90
126
  device.is_oneip_rx # OneIP receiver
91
127
  device.is_oneip_tz # OneIP transceiver
92
128
  device.is_oneip_multiviewer # OneIP multiviewer
129
+ device.supports_video_wall # sink can crop its source to a video wall window
130
+ # (advertised ~1s after boot by the v2ipwall module,
131
+ # and never withdrawn once advertised)
93
132
 
94
133
  # iterate bays
95
134
  for port, bay in device.bays.items():
@@ -304,24 +343,28 @@ log = await device.get_log()
304
343
  result = await device.get_api("system/status")
305
344
  ```
306
345
 
307
- ## OneIP (V2IP) Devices
346
+ ## Pulse-Eight OneIP Devices
308
347
 
309
- OneIP devices expose additional streaming properties:
348
+ [Pulse-Eight OneIP](https://www.pulse-eight.com/p/248/oneip-tx) HDMI-over-IP devices expose additional streaming properties:
310
349
 
311
350
  ```python
312
- # V2IP stream source addresses
351
+ # stream source addresses
313
352
  if device.is_v2ip and device.v2ip_sources:
314
353
  for source in device.v2ip_sources:
315
354
  print(f"Video: {source.video.ip}:{source.video.port}")
316
355
  print(f"Audio: {source.audio.ip}:{source.audio.port}")
317
356
 
318
- # V2IP device details (encoder/decoder config)
357
+ # stream details (encoder/decoder config)
319
358
  if device.v2ip_details:
320
359
  details = device.v2ip_details
321
360
  print(f"Video: {details.video}")
322
- print(f"TX rate: {details.tx_rate}")
361
+ print(f"TX rate: {details.tx_rate}") # None when the sender offered no rate
362
+
363
+ # per-stream DSCP marking (None from peers that predate it)
364
+ if details.dscp and details.dscp.complete:
365
+ print(f"DSCP: {details.dscp}") # video / audio / anc, 0..63
323
366
 
324
- # V2IP statistics
367
+ # streaming statistics
325
368
  await device.read_stats(enable=True) # start collecting
326
369
  # ... later ...
327
370
  stats = device.v2ip_stats
@@ -336,9 +379,9 @@ if device.v2ip_firmware_versions:
336
379
  print(f"{fw_type}: {fw.version}")
337
380
  ```
338
381
 
339
- ## OneIP Multiviewer
382
+ ## Pulse-Eight OneIP Multiviewer
340
383
 
341
- Control multiviewer-specific settings:
384
+ Control [OneIP Multiviewer](https://www.pulse-eight.com/p/248/oneip-tx)-specific settings:
342
385
 
343
386
  ```python
344
387
  from mx_remote import (
@@ -357,6 +357,31 @@ class V2IPStreamSources:
357
357
  def arc(self) -> V2IPStreamSource|None:
358
358
  ''' audio return stream source '''
359
359
 
360
+ @property
361
+ def valid(self) -> bool:
362
+ """
363
+ True when this entry carries a usable address.
364
+
365
+ Video and anc must both be multicast with a non-zero port; audio is
366
+ optional and rides along. A sender without MXR_FEATURE_CONFIG_INITIALISED
367
+ can put stack contents in bay 0's addresses under its own uid, which
368
+ rarely satisfy both halves of this test.
369
+ """
370
+ return v2ip_av_source_valid(self.video, self.anc)
371
+
372
+ @property
373
+ def cleared(self) -> bool:
374
+ """
375
+ True when every stream is zeroed, which reports that the source is gone.
376
+
377
+ Distinct from an entry that is neither valid nor cleared, which is
378
+ malformed. Treating a cleared entry as unusable discards the only signal
379
+ that a source went away.
380
+ """
381
+ return (v2ip_stream_cleared(self.video)
382
+ and v2ip_stream_cleared(self.audio)
383
+ and v2ip_stream_cleared(self.anc))
384
+
360
385
  class V2IPStreamSourcesList(list[V2IPStreamSources]):
361
386
  ''' list of V2IP sources '''
362
387
 
@@ -1183,27 +1208,154 @@ class DeviceV2IPScalingSettings(ABC):
1183
1208
  @property
1184
1209
  @abstractmethod
1185
1210
  def mode(self) -> int:
1186
- pass
1211
+ '''Scaling mode, carried only when flags has MXR_SCALING_FLAG_MODE_VALID'''
1187
1212
 
1188
1213
  @property
1189
1214
  @abstractmethod
1190
1215
  def refresh(self) -> int:
1191
- pass
1216
+ '''Refresh rate paired with mode, under the same MXR_SCALING_FLAG_MODE_VALID marker'''
1192
1217
 
1193
1218
  @property
1194
1219
  @abstractmethod
1195
1220
  def flags(self) -> int:
1196
- pass
1221
+ '''mxr_scaling_config.flags.
1222
+
1223
+ MXR_SCALING_FLAG_MODE_VALID and MXR_SCALING_FLAG_OPTIONS_VALID mark
1224
+ which half a sender meant to send, and are separately valid; a sender
1225
+ setting neither is offering no scaling at all. MXR_SCALING_FLAG_AUTO_SCALING
1226
+ is the only option bit with a defined meaning.
1227
+ '''
1228
+
1229
+ def v2ip_stream_valid(stream:'V2IPStreamSource|None') -> bool:
1230
+ """
1231
+ True when a stream source carries an address a frame actually meant to send.
1232
+
1233
+ Mirrors mxr_v2ip_stream_valid(): a multicast ip and a non-zero port. A
1234
+ controller write that offers no addresses leaves the block zeroed, which
1235
+ fails both tests.
1236
+ """
1237
+ if (stream is None):
1238
+ return False
1239
+ try:
1240
+ return ipaddress.IPv4Address(stream.ip).is_multicast and (stream.port != 0)
1241
+ except ValueError:
1242
+ return False
1243
+
1244
+ def v2ip_av_source_valid(video:'V2IPStreamSource|None', anc:'V2IPStreamSource|None') -> bool:
1245
+ """
1246
+ True when an av source block carries addresses a frame meant to send.
1247
+
1248
+ Mirrors mxr_v2ip_av_source_valid(): video and anc must both be valid, audio
1249
+ is optional and is carried along with them.
1250
+ """
1251
+ return v2ip_stream_valid(video) and v2ip_stream_valid(anc)
1252
+
1253
+ def v2ip_stream_cleared(stream:'V2IPStreamSource|None') -> bool:
1254
+ """True when a stream source is zeroed, which is how "no source" is spelled."""
1255
+ return (stream is not None) and (stream.ip == '0.0.0.0') and (stream.port == 0)
1256
+
1257
+ class V2IPScalingSettings(DeviceV2IPScalingSettings):
1258
+ """Plain holder for a merged mxr_scaling_config."""
1259
+ def __init__(self, mode:int, refresh:int, flags:int) -> None:
1260
+ self._mode = mode
1261
+ self._refresh = refresh
1262
+ self._flags = flags
1263
+
1264
+ @property
1265
+ def mode(self) -> int:
1266
+ return self._mode
1267
+
1268
+ @property
1269
+ def refresh(self) -> int:
1270
+ return self._refresh
1271
+
1272
+ @property
1273
+ def flags(self) -> int:
1274
+ return self._flags
1275
+
1276
+ def __str__(self) -> str:
1277
+ return f"mode:{self._mode} refresh:{self._refresh} flags:{self._flags:02X}"
1278
+
1279
+ def __repr__(self) -> str:
1280
+ return str(self)
1281
+
1282
+ class V2IPDscpConfig:
1283
+ """
1284
+ Per-stream DSCP marking carried in a V2IP device configuration.
1285
+
1286
+ DSCP 0 (CS0) is a legal marking, so each wire byte carries a set bit
1287
+ alongside its value and a stream whose byte is unset reads back as None
1288
+ here. Firmware treats the marking as all-or-nothing: it only applies one
1289
+ when all three streams carry a value, and otherwise falls back to CS2
1290
+ (``V2IP_DSCP_DEFAULT``) - ``complete`` reports which case a frame is in.
1291
+ """
1292
+ def __init__(self, video:int|None, audio:int|None, anc:int|None) -> None:
1293
+ self._video = video
1294
+ self._audio = audio
1295
+ self._anc = anc
1296
+
1297
+ @property
1298
+ def video(self) -> int|None:
1299
+ return self._video
1300
+
1301
+ @property
1302
+ def audio(self) -> int|None:
1303
+ return self._audio
1304
+
1305
+ @property
1306
+ def anc(self) -> int|None:
1307
+ return self._anc
1308
+
1309
+ @property
1310
+ def carried(self) -> bool:
1311
+ """
1312
+ True when this frame carried a marking at all.
1313
+
1314
+ Gated on the video byte alone, matching the firmware cache, which stores
1315
+ all three bytes verbatim. Not ``complete``: a partial set does overwrite
1316
+ a peer's marking, so treating it as absent would report a marking the
1317
+ peer does not hold.
1318
+ """
1319
+ return (self._video is not None)
1320
+
1321
+ @property
1322
+ def complete(self) -> bool:
1323
+ """
1324
+ True when all three streams carry a marking.
1325
+
1326
+ Whether a marking is applied, as opposed to carried: firmware falls back
1327
+ to CS2 on all three streams unless every byte is set, so a partial set is
1328
+ cached and reported but never applied.
1329
+ """
1330
+ return (self._video is not None) and (self._audio is not None) and (self._anc is not None)
1331
+
1332
+ def __eq__(self, other:Any) -> bool:
1333
+ if not isinstance(other, V2IPDscpConfig):
1334
+ return False
1335
+ return (self._video == other._video) and (self._audio == other._audio) and (self._anc == other._anc)
1336
+
1337
+ def __str__(self) -> str:
1338
+ if not self.carried:
1339
+ return "no marking"
1340
+ def one(v:int|None) -> str:
1341
+ return str(v) if (v is not None) else "unset"
1342
+ rv = f"video:{one(self._video)} audio:{one(self._audio)} anc:{one(self._anc)}"
1343
+ # a partial set is cached and reported, but firmware will not apply it
1344
+ return rv if self.complete else (rv + " (partial, not applied)")
1345
+
1346
+ def __repr__(self) -> str:
1347
+ return str(self)
1197
1348
 
1198
1349
  class DeviceV2IPDetails:
1199
1350
  """ V2IP stream source details for a device """
1200
- def __init__(self, video:V2IPStreamSource|None, audio:V2IPStreamSource|None, anc:V2IPStreamSource|None, arc:V2IPStreamSource|None, tx_rate:int|None, scaling:DeviceV2IPScalingSettings|None) -> None:
1351
+ def __init__(self, video:V2IPStreamSource|None, audio:V2IPStreamSource|None, anc:V2IPStreamSource|None, arc:V2IPStreamSource|None, tx_rate:int|None, scaling:DeviceV2IPScalingSettings|None, dscp:V2IPDscpConfig|None=None) -> None:
1201
1352
  self._video = video
1202
1353
  self._audio = audio
1203
1354
  self._anc = anc
1204
1355
  self._arc = arc
1205
1356
  self._tx_rate = tx_rate
1206
1357
  self._scaling = scaling
1358
+ self._dscp = dscp
1207
1359
 
1208
1360
  @property
1209
1361
  def has_config(self) -> bool:
@@ -1227,12 +1379,81 @@ class DeviceV2IPDetails:
1227
1379
 
1228
1380
  @property
1229
1381
  def tx_rate(self) -> int|None:
1382
+ '''Source rate this device transmits at, or None if none is known.
1383
+
1384
+ Only a rate within V2IP_SOURCE_RATE_MIN..MAX is one a sender meant to
1385
+ send; anything else is an absent offer rather than a rate, and merge()
1386
+ keeps the previously known value instead of caching what was on the wire.
1387
+ '''
1230
1388
  return self._tx_rate
1231
1389
 
1232
1390
  @property
1233
1391
  def scaling(self) -> DeviceV2IPScalingSettings|None:
1234
1392
  return self._scaling
1235
1393
 
1394
+ @property
1395
+ def dscp(self) -> V2IPDscpConfig|None:
1396
+ """Per-stream DSCP marking, or None from peers that predate it."""
1397
+ return self._dscp
1398
+
1399
+ def merge(self, previous:'DeviceV2IPDetails|None') -> 'DeviceV2IPDetails':
1400
+ """
1401
+ Carry forward every field this frame did not carry.
1402
+
1403
+ A controller writing one field of a peer's config leaves the others
1404
+ zeroed on the wire, so each is applied only behind its own validity
1405
+ marker. See FrameV2IPDeviceConfiguration for the markers.
1406
+ """
1407
+ if (previous is None):
1408
+ return self
1409
+
1410
+ if v2ip_av_source_valid(self._video, self._anc):
1411
+ video, audio, anc = self._video, self._audio, self._anc
1412
+ else:
1413
+ video, audio, anc = previous._video, previous._audio, previous._anc
1414
+ arc = self._arc if v2ip_stream_valid(self._arc) else previous._arc
1415
+ tx_rate = self._tx_rate if v2ip_rate_valid(self._tx_rate) else previous._tx_rate
1416
+ dscp = self._dscp if ((self._dscp is not None) and self._dscp.carried) else previous._dscp
1417
+ scaling = self._merge_scaling(previous._scaling)
1418
+
1419
+ if (video is self._video) and (audio is self._audio) and (anc is self._anc) and (arc is self._arc) and (tx_rate == self._tx_rate) and (dscp is self._dscp) and (scaling is self._scaling):
1420
+ return self
1421
+ return DeviceV2IPDetails(video=video, audio=audio, anc=anc, arc=arc,
1422
+ tx_rate=tx_rate, scaling=scaling, dscp=dscp)
1423
+
1424
+ def _merge_scaling(self, previous:DeviceV2IPScalingSettings|None) -> DeviceV2IPScalingSettings|None:
1425
+ """
1426
+ Merge a scaling config field by field, the way firmware applies it.
1427
+
1428
+ The mode/refresh pair and the options nibble are separately valid, so a
1429
+ write carrying only one of them must leave the other as it was.
1430
+ """
1431
+ incoming = self._scaling
1432
+ if (incoming is None) or (previous is None):
1433
+ return incoming if (incoming is not None) else previous
1434
+ mode_valid = ((incoming.flags & MXR_SCALING_FLAG_MODE_VALID) != 0)
1435
+ options_valid = ((incoming.flags & MXR_SCALING_FLAG_OPTIONS_VALID) != 0)
1436
+ if not mode_valid and not options_valid:
1437
+ # carries no scaling at all
1438
+ return previous
1439
+ if mode_valid and options_valid:
1440
+ return incoming
1441
+ mode = incoming.mode if mode_valid else previous.mode
1442
+ refresh = incoming.refresh if mode_valid else previous.refresh
1443
+ flags = previous.flags
1444
+ if mode_valid:
1445
+ flags |= MXR_SCALING_FLAG_MODE_VALID
1446
+ else:
1447
+ # Options only. Carry AUTO_SCALING alone rather than the whole upper
1448
+ # nibble: bits 4..6 have no defined meaning, and on a receiver-capable
1449
+ # unit running firmware before the scaling-config initialisation fix
1450
+ # they are stack noise - the sender declares mxr_scaling_config
1451
+ # uninitialised and only ever |= flags onto it. Masking to the bit we
1452
+ # mean cannot lose information even on fixed firmware, and stops the
1453
+ # noise at this boundary instead of caching it as a peer's config.
1454
+ flags = (flags & 0x0F) | MXR_SCALING_FLAG_OPTIONS_VALID | (incoming.flags & MXR_SCALING_FLAG_AUTO_SCALING)
1455
+ return V2IPScalingSettings(mode=mode, refresh=refresh, flags=flags)
1456
+
1236
1457
  class DeviceV2IPSink:
1237
1458
  """
1238
1459
  A V2IP sink's effective multicast subscriptions and resolved audio format.
@@ -1591,6 +1812,23 @@ class DeviceBase(ABC):
1591
1812
  def is_oneip_multiviewer(self) -> bool:
1592
1813
  '''True if this device is a OneIP Multiviewer'''
1593
1814
 
1815
+ @property
1816
+ @abstractmethod
1817
+ def supports_video_wall(self) -> bool:
1818
+ '''True if this sink can crop its source to a video wall window (MXR_FEATURE_VIDEO_WALL)'''
1819
+
1820
+ @property
1821
+ @abstractmethod
1822
+ def config_initialised(self) -> bool:
1823
+ '''True if every configuration block this device broadcasts is fully written.
1824
+
1825
+ A device without MXR_FEATURE_CONFIG_INITIALISED builds some config
1826
+ payloads from stack locals it never zeroes, so these are unreliable from
1827
+ it: the scaling block in V2IP_DEVICE_CFG, bay 0's addresses in
1828
+ SYS_BAY_V2IP_SOURCES, and the padding beside the rc target in
1829
+ RC_SETTINGS. None of that is detectable within a frame.
1830
+ '''
1831
+
1594
1832
  @property
1595
1833
  @abstractmethod
1596
1834
  def is_oneip_tz(self) -> bool: