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