pax_global_header00006660000000000000000000000064147600766470014533gustar00rootroot0000000000000052 comment=0290783a832abdd83bbc7622723c2319d1373c40 python-MotionMount-2.3.1/000077500000000000000000000000001476007664700153455ustar00rootroot00000000000000python-MotionMount-2.3.1/.gitignore000066400000000000000000000001001476007664700173240ustar00rootroot00000000000000__pycache__ dist *.egg-info .DS_Store docs/_build .idea venv python-MotionMount-2.3.1/LICENSE.txt000066400000000000000000000020611476007664700171670ustar00rootroot00000000000000MIT License Copyright (c) 2023 Vogel's Products Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. python-MotionMount-2.3.1/MAINTENANCE.md000066400000000000000000000005511476007664700174120ustar00rootroot00000000000000# Build Building the distributable package for Pypi consists of: `python -m build` Uploading to Pypi can be done using `twine upload dist/*`. When asked for a username use `__token__` and supply an API token as password. # Documentation Documentation is generated using Sphinx. Run `make html` inside the `docs` folder to generate the documentation. python-MotionMount-2.3.1/README.md000066400000000000000000000035741476007664700166350ustar00rootroot00000000000000# Introduction This Python module allows control of the TVM 7675 Pro (SIGNATURE) series of MotionMount's from Vogel's Products. # Getting Started This module can be installed using the following command: `pip install python-MotionMount` In your Python code you can then use the module as follows: ``` import asyncio import motionmount ip = "MMF8A55F.local." # Can also be "169.254.13.16" or similar port = 23 # The best way to get the port number is using zeroconf, but it's likely '23' async def main(): mm = MotionMount(ip, port) try: await mm.connect() await mm.go_to_preset(1) print(f"Extension: {mm.extension}") name = await mm.get_name() print(f"The name is: \"{name}\"") await mm.go_to_position(50, -50) except Exception as e: print(f"Something bad happened: {e}") finally: await asyncio.sleep(1) await mm.disconnect() if __name__ == '__main__': asyncio.run(main()) ``` To get the IP address of the MotionMount you can use [pyzeroconf](https://github.com/paulsm/pyzeroconf) or you can use a manual tool like `dns-sd` in the macOS Terminal or a GUI tool like [Discovery](https://apps.apple.com/nl/app/discovery-dns-sd-browser/id1381004916?mt=12) (macOS) or [Bonjour Browser](https://hobbyistsoftware.com/bonjourbrowser) (Windows) A simple example using `pyzeroconf` is included in the `examples` folder. If you want to run the examples from a clone of the repository you can use a command similar to: `PYTHONPATH=./src/motionmount python examples/simple.py` # Changelog 1.0.1: - Fix bug in allowed preset indices 2.0.0: - Include position data in presets 2.1.0: - Add timeout (15 s) to `connect()` 2.2.0: - Add support for authentication to the MotionMount 2.3.0: - Add new `system_status` property for pre-parsed aggregated system status python-MotionMount-2.3.1/docs/000077500000000000000000000000001476007664700162755ustar00rootroot00000000000000python-MotionMount-2.3.1/docs/Makefile000066400000000000000000000011721476007664700177360ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) python-MotionMount-2.3.1/docs/conf.py000066400000000000000000000020261476007664700175740ustar00rootroot00000000000000# Configuration file for the Sphinx documentation builder. # # For the full list of built-in configuration values, see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information import os import sys sys.path.insert(0, os.path.abspath('..')) project = 'python-MotionMount' copyright = "2023, Vogel's Products" author = "Vogel's Products" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration extensions = ['sphinx.ext.autodoc'] templates_path = ['_templates'] exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output html_theme = 'alabaster' html_static_path = ['_static'] python-MotionMount-2.3.1/docs/index.rst000066400000000000000000000007261476007664700201430ustar00rootroot00000000000000.. python-MotionMount documentation master file, created by sphinx-quickstart on Tue Sep 19 12:54:35 2023. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. Welcome to python-MotionMount's documentation! ============================================== .. toctree:: :maxdepth: 2 :caption: Contents: Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` python-MotionMount-2.3.1/docs/make.bat000066400000000000000000000014401476007664700177010ustar00rootroot00000000000000@ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=. set BUILDDIR=_build %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.https://www.sphinx-doc.org/ exit /b 1 ) if "%1" == "" goto help %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% :end popd python-MotionMount-2.3.1/docs/source/000077500000000000000000000000001476007664700175755ustar00rootroot00000000000000python-MotionMount-2.3.1/docs/source/modules.rst000066400000000000000000000001061476007664700217740ustar00rootroot00000000000000motionmount =========== .. toctree:: :maxdepth: 4 motionmount python-MotionMount-2.3.1/docs/source/motionmount.rst000066400000000000000000000005211476007664700227150ustar00rootroot00000000000000motionmount package =================== Submodules ---------- motionmount.motionmount module ------------------------------ .. automodule:: motionmount.motionmount :members: :undoc-members: :show-inheritance: Module contents --------------- .. automodule:: motionmount :members: :undoc-members: :show-inheritance: python-MotionMount-2.3.1/examples/000077500000000000000000000000001476007664700171635ustar00rootroot00000000000000python-MotionMount-2.3.1/examples/async-ZeroConf.py000066400000000000000000000056731476007664700224100ustar00rootroot00000000000000#!/usr/bin/env python3 import logging from typing import cast from zeroconf import IPVersion, ServiceStateChange, Zeroconf from zeroconf.asyncio import ( AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf, ) from motionmount import * def async_on_service_state_change( zeroconf: Zeroconf, service_type: str, name: str, state_change: ServiceStateChange ) -> None: print(f"Service {name} of type {service_type} state changed: {state_change}") if state_change is not ServiceStateChange.Added: return asyncio.ensure_future(async_display_service_info(zeroconf, service_type, name)) async def async_display_service_info(zeroconf: Zeroconf, service_type: str, name: str) -> None: info = AsyncServiceInfo(service_type, name) await info.async_request(zeroconf, 3000) print("Info from zeroconf.get_service_info: %r" % (info)) if info: addresses = ["%s:%d" % (addr, cast(int, info.port)) for addr in info.parsed_scoped_addresses()] print(" Name: %s" % name) print(" Addresses: %s" % ", ".join(addresses)) print(" Weight: %d, priority: %d" % (info.weight, info.priority)) print(f" Server: {info.server}") if info.properties: print(" Properties are:") for key, value in info.properties.items(): print(f" {key}: {value}") else: print(" No properties") mm = MotionMount(info.parsed_addresses()[0], info.port) try: await mm.connect() await mm.go_to_preset(1) print(f"Extension: {mm.extension}") name = await mm.get_name() print(f"The name is: \"{name}\"") await mm.go_to_position(50, -50) except Exception as e: print(f"Something bad happened: {e}") finally: await mm.disconnect() else: print(" No info") print('\n') class AsyncRunner: def __init__(self) -> None: self.aiobrowser: Optional[AsyncServiceBrowser] = None self.aiozc: Optional[AsyncZeroconf] = None async def async_run(self) -> None: self.aiozc = AsyncZeroconf(ip_version=IPVersion.V4Only) services = ["_tvm._tcp.local."] print("\nBrowsing %s service(s), press Ctrl-C to exit...\n" % services) self.aiobrowser = AsyncServiceBrowser( self.aiozc.zeroconf, services, handlers=[async_on_service_state_change] ) while True: await asyncio.sleep(1) async def async_close(self) -> None: assert self.aiozc is not None assert self.aiobrowser is not None await self.aiobrowser.async_cancel() await self.aiozc.async_close() if __name__ == '__main__': logging.basicConfig(level=logging.DEBUG) loop = asyncio.get_event_loop() runner = AsyncRunner() try: loop.run_until_complete(runner.async_run()) except KeyboardInterrupt: loop.run_until_complete(runner.async_close()) python-MotionMount-2.3.1/examples/simple.py000066400000000000000000000014051476007664700210260ustar00rootroot00000000000000import asyncio import motionmount ip = "MMF8A55F.local." # Can also be "169.254.13.16" or similar port = 23 # The best way to get the port number is using zeroconf, but it's likely '23' def callback(): print("Update received") async def main(): mm = motionmount.MotionMount(ip, port) mm.add_listener(callback) try: await mm.connect() await mm.go_to_preset(1) print(f"Extension: {mm.extension}") print(f"The name is: \"{mm.name}\"") print(f"The mac is: \"{mm.mac}\"") await mm.go_to_position(50, -50) except Exception as e: print(f"Something bad happened: {e}") finally: await asyncio.sleep(1) await mm.disconnect() if __name__ == '__main__': asyncio.run(main()) python-MotionMount-2.3.1/pyproject.toml000066400000000000000000000001251476007664700202570ustar00rootroot00000000000000[build-system] requires = ['setuptools>=42'] build-backend = 'setuptools.build_meta' python-MotionMount-2.3.1/setup.cfg000066400000000000000000000014671476007664700171760ustar00rootroot00000000000000[metadata] name = python-MotionMount version = 2.3.1 author = Jaakko Laiho author_email = j.laiho@vogels.com description = Control your SIGNATURE MotionMount TVM7675 Pro using Python long_description = file: README.md, LICENSE.txt long_description_content_type = text/markdown url = https://github.com/vogelsproducts/python-MotionMount project_urls = Bug Tracker = https://github.com/vogelsproducts/python-MotionMount/issues repository = https://github.com/vogelsproducts/python-MotionMount classifiers = Programming Language :: Python :: 3 License :: OSI Approved :: MIT License Operating System :: OS Independent [options] package_dir = = src packages = find: python_requires = >=3.6 include_package_data = true [options.packages.find] where = src [options.package_data] motionmount = py.typed python-MotionMount-2.3.1/src/000077500000000000000000000000001476007664700161345ustar00rootroot00000000000000python-MotionMount-2.3.1/src/motionmount/000077500000000000000000000000001476007664700205245ustar00rootroot00000000000000python-MotionMount-2.3.1/src/motionmount/__init__.py000066400000000000000000000003131476007664700226320ustar00rootroot00000000000000#!/usr/bin/env python # -*- coding: utf-8 -*- # # Copyright 2023 Vogel's Products # # This file is part of python-MotionMount # # SPDX-License-Identifier: MIT # from motionmount.motionmount import * python-MotionMount-2.3.1/src/motionmount/motionmount.py000066400000000000000000000513401476007664700234710ustar00rootroot00000000000000#!/usr/bin/env python # -*- coding: utf-8 -*- # # Copyright 2023 Vogel's Products # # This file is part of python-MotionMount # # SPDX-License-Identifier: MIT # import collections import asyncio import struct from typing import Optional, Callable, Deque, Any, Union, List from enum import Enum, IntEnum, IntFlag, auto class MotionMountResponse(IntEnum): """ Enum representing possible response codes from MotionMount. These are derived from HTTP response code and have a corresponding meaning. """ Unknown = 0, Accepted = 202, BadRequest = 400, Unauthorised = 401, Forbidden = 403, NotFound = 404, MethodNotAllowed = 405, URITooLong = 414, class MotionMountValueType(Enum): """ Enum representing possible value types for MotionMount requests. """ Integer = 0, String = 1, Bytes = 2, Bool = 3, IPv4 = 4, Void = 5, class MotionMountSystemError(IntFlag): """ Enum representing the (aggregated) system errors of the MotionMount. """ MotorError = auto() HDMICECError = auto() ObstructionDetected = auto() TVWidthConstraintError = auto() InternalError = auto() class MotionMountError(Exception): """ Base exception class for MotionMount errors. """ pass class NotConnectedError(MotionMountError): """ Exception raised when not connected to MotionMount. """ def __str__(self): return "Not connected to MotionMount" class MotionMountResponseError(MotionMountError): """ Exception raised for MotionMount response errors. Attributes: response_value (MotionMountResponse): The response code received. """ def __init__(self, value: MotionMountResponse): self.response_value = value class Request: """ Represents a request to be sent to the MotionMount. Args: key (str): The key for the request. value_type (MotionMountValueType): The value type for the request. value (Optional[str]): The optional value for the request. Attributes: key (str): The key for the request. value_type (MotionMountValueType): The value type for the request. value (Optional[str]): The optional value for the request. future (asyncio.Future): The asyncio Future associated with the request. """ def __init__(self, key: str, value_type: MotionMountValueType, value: Optional[str] = None): self.key = key self.value = value self.value_type = value_type event_loop = asyncio.get_event_loop() self.future = event_loop.create_future() def encoded(self) -> bytes: """ Encodes this request for sending over the network. Returns: bytes: The encoded request. """ if self.value is None: return f"{self.key}\n".encode() else: return f"{self.key} = {self.value}\n".encode() def _convert_value(value, value_type: MotionMountValueType): """ Convert a value to the specified MotionMount value type. Args: value: The value to convert. value_type (MotionMountValueType): The target value type. Returns: Any: The converted value. """ if value_type == MotionMountValueType.Integer: return int(value) elif value_type == MotionMountValueType.String: return value.strip("\"") elif value_type == MotionMountValueType.Bytes: return bytes.fromhex(value.strip("[]")) elif value_type == MotionMountValueType.Bool: return bool(int(value)) elif value_type == MotionMountValueType.Void: return value else: raise ValueError("Unknown value type") class Preset: """Class for storing preset related data""" index: int name: str extension: int turn: int def __init__(self, index, name, extension, turn): self.index = index self.name = name self.extension = extension self.turn = turn class MotionMount: """ Class to represent a MotionMount. You can create one with the IP address and port number which should be used to connect. After creation, you should call `connect` first, before accessing other properties. When you're done, call `disconnect` to clean up resources. Args: address (str): The IP address of the MotionMount. port (int): The port number to use for the connection. notification_callback: Will be called when a notification has been received. """ def __init__(self, address: str, port: int): self.address = address self.port = port self._callbacks: list[Callable[[], None]] = [] self._requests: Deque['Request'] = collections.deque() self._writer: Optional[asyncio.StreamWriter] = None self._reader_task: Optional[asyncio.Task[Any]] = None self._mac = b'\x00\x00\x00\x00\x00\x00' self._name = None self._extension = None self._turn = None self._is_moving = False self._target_extension = None self._target_turn = None self._error_status = None self._authentication_status = 0x0 @property def mac(self) -> bytes: """Returns the (primary) mac address""" return self._mac @property def name(self) -> Optional[str]: """Returns the name""" return self._name @property def extension(self) -> Optional[int]: """The current extension of the MotionMount, normally between 0 - 100 but slight excursions can occur due to calibration errors, mechanical play and round-off errors""" return self._extension @property def turn(self) -> Optional[int]: """The current rotation of the MotionMount, normally between -100 - 100 but slight excursions can occur due to calibration errors, mechanical play and round-off errors""" return self._turn @property def is_moving(self) -> Optional[bool]: """When true the MotionMount is (electrically) moving to another position""" return self._is_moving @property def target_extension(self) -> Optional[int]: """The most recent extension the MotionMount tried to move to""" return self._target_extension @property def target_turn(self) -> Optional[int]: """The most recent turn the MotionMount tried to move to""" return self._target_turn @property def error_status(self) -> Optional[int]: """The error status of the MotionMount. See the protocol documentation for details. It's recommended to use the `system_status` property for pre-parsed status information""" return self._error_status @property def system_status(self) -> MotionMountSystemError: """The status of the MotionMount. These flags are aggregated errors from the underlying detailed errors.""" errors = ((self.error_status or 0) >> 16) & 0x7fff # We're only interested in the active errors status = MotionMountSystemError(0) if errors == 0: # We don't have new-style errors, so decode old-style error_status = (self.error_status or 0) if error_status & (1 << 31): if error_status & (1 << 10): status |= MotionMountSystemError.MotorError elif error_status & (1 << 4): status |= MotionMountSystemError.HDMICECError else: status |= MotionMountSystemError.InternalError else: if errors & (1 << 10): status |= MotionMountSystemError.MotorError if errors & (1 << 4): status |= MotionMountSystemError.HDMICECError if errors & (1 << 11): status |= MotionMountSystemError.ObstructionDetected if errors & (1 << 12): status |= MotionMountSystemError.TVWidthConstraintError if errors & (0b1111100000): status |= MotionMountSystemError.InternalError return status @property def is_authenticated(self) -> bool: """Indicates whether we're authenticated to the MotionMount (or no authentication is needed).""" return self._authentication_status & 0x80 == 0x80 @property def can_authenticate(self) -> Union[bool, int]: """Indicates whether we can authenticate. When there are too many failed authentication attempts the MotionMount enforces a backoff time. This propperty either returns `True` if authentication is possible or the (last known) backoff time.""" if self.is_authenticated or self._authentication_status <= 3: return True else: return (self._authentication_status-3) * 3 async def connect(self) -> None: """ Connect to the MotionMount. Properties that are updated by notifications are pre-fetched """ connection_future = asyncio.open_connection(self.address, self.port) reader, writer = await asyncio.wait_for(connection_future, timeout=15) self._writer = writer self._reader_task = asyncio.create_task(self._reader(reader)) try: await self._update_mac() except MotionMountResponseError as e: # We're fine with a #404, as older firmware doesn't support the mac property if e.response_value != MotionMountResponse.NotFound: raise await self.update_name() await self.update_position() await self.update_error_status() await self.update_authentication_status() async def disconnect(self) -> None: """ Disconnect from the MotionMount. """ writer = self._writer # Close the stream if self._reader_task is not None: self._reader_task.cancel() if self._writer is not None: self._writer.close() # Cancel all waiting requests for request in self._requests: request.future.cancel() # Clean up our state self._writer = None self._reader_task = None self._requests.clear() # Let listeners know that we've a changed state (disconnected) for callback in self._callbacks: callback() # Wait for the stream to really close if writer is not None: try: await writer.wait_closed() except: pass # We're not interested in exceptions def add_listener(self, callback: Callable[[], None]) -> None: """Register callback as a listener for updates.""" self._callbacks.append(callback) def remove_listener(self, callback: Callable[[], None]) -> None: self._callbacks.remove(callback) @property def is_connected(self) -> bool: return self._writer is not None async def _update_mac(self): """Update the mac address""" await self._request(Request("mac", MotionMountValueType.Void)) async def update_name(self): """Update the name of the MotionMount.""" await self._request(Request("configuration/name", MotionMountValueType.Void)) async def update_position(self): """ Fetch the current position of the MotionMount. """ # We mark the value types as Void, as we've no further interest in the actual value # We just want to trigger the notification logic await self._request(Request("mount/extension/current", MotionMountValueType.Void)) await self._request(Request("mount/turn/current", MotionMountValueType.Void)) async def update_error_status(self): """Fetch the error status from the MotionMount""" # We mark the value types as Void, as we've no further interest in the actual value # We just want to trigger the notification logic await self._request(Request("mount/errorStatus", MotionMountValueType.Void)) async def update_authentication_status(self): """Fetch authentication status from the MotionMount.""" # We mark the value types as Void, as we've no further interest in the actual value # We just want to trigger the notification logic await self._request(Request("configuration/authentication/status", MotionMountValueType.Void)) async def get_presets(self) -> List[Preset]: """Gets the valid user presets from the device.""" presets = [] for i in range(1,8): valid = await self._request(Request(f"mount/preset/{i}/active", MotionMountValueType.Bool)) if valid: name = await self._request(Request(f"mount/preset/{i}/name", MotionMountValueType.String)) extension = await self._request(Request(f"mount/preset/{i}/extension", MotionMountValueType.Integer)) turn = await self._request(Request(f"mount/preset/{i}/turn", MotionMountValueType.Integer)) preset = Preset(i, name, extension, turn) presets.append(preset) return presets async def go_to_preset(self, position: int): """ Go to a preset position. Preset 0 is the (fixed) Wall position. Args: position (int): The preset position to go to (0 - 7). Raises: ValueError: If the position is out of range. """ if position < 0 or position > 7: raise ValueError("position must be in the range [0...7]") await self._request(Request(f"mount/preset/index = {position}", MotionMountValueType.Void)) async def go_to_position(self, extension: int, turn: int): """ Go to a specific position. Args: extension (int): The extension value (0 - 100) turn (int): The turn value. (-100 - 100) Raises: ValueError: If the extension or turn values are out of range. """ if extension < 0 or extension > 100: raise ValueError("extension must be in the range [0...100]") if turn < -100 or turn > 100: raise ValueError("turn must be in the range [-100...100]") value_bytes = struct.pack('>Hh', extension, turn) await self._request(Request(f"mount/preset/position = [{value_bytes.hex()}]", MotionMountValueType.Void)) async def set_extension(self, extension: int): """ Set the extension value. Args: extension (int): The extension value (0 - 100). Raises: ValueError: If the extension value is out of range. """ if extension < 0 or extension > 100: raise ValueError("extension must be in the range [0...100]") await self._request(Request(f"mount/extension/target = {extension}", MotionMountValueType.Void)) async def set_turn(self, turn: int): """ Set the turn value. Args: turn (int): The turn value (-100 - 100). Raises: ValueError: If the turn value is out of range. """ if turn < -100 or turn > 100: raise ValueError("turn must be in the range [-100...100]") await self._request(Request(f"mount/turn/target = {turn}", MotionMountValueType.Void)) async def authenticate(self, pin: int): """ Provide a pin to authenticate. Args: pin (int): The pin code for the 'User' level to authenticate with (1-9999) Raises: ValueError: If the pin code is outside the range. """ if pin < 1 or pin > 9999: raise ValueError("pin must be in the range [1...9999]") await self._request(Request(f"configuration/authentication/pin = {pin}", MotionMountValueType.Void)) await self.update_authentication_status() async def _request(self, request: Request): """ Enqueues a request, waits for possible earlier requests, and then waits for the request to finish. Args: request (Request): The request to send. Returns: Any: The response value. """ # Ignore requests when we're not connected if self._writer is None: raise NotConnectedError try: # Check a possible previous request and wait for it to finish previous_request = None if len(self._requests) > 0: previous_request = self._requests[len(self._requests) - 1] # Add ourselves to the queue self._requests.append(request) # Wait for the previous request if previous_request is not None: await previous_request.future # We're ready to go! self._writer.write(request.encoded()) await self._writer.drain() # Wait for our request to finish value_any = await asyncio.wait_for(request.future, timeout=5.0) value = _convert_value(value_any, request.value_type) return value except MotionMountResponseError: pass except: # Make sure we disconnect when there was a failure await self.disconnect() raise def _update_properties(self, key: str, value: str): """ Update internal properties based on key-value pairs received from MotionMount. Args: key (str): The key from MotionMount. value (str): The corresponding value. """ if key == "mount/extension/current": self._extension = _convert_value(value, MotionMountValueType.Integer) elif key == "mount/turn/current": self._turn = _convert_value(value, MotionMountValueType.Integer) elif key == "mount/isMoving": self._is_moving = _convert_value(value, MotionMountValueType.Bool) elif key == "mount/extension/target": self._target_extension = _convert_value(value, MotionMountValueType.Integer) elif key == "mount/turn/target": self._target_turn = _convert_value(value, MotionMountValueType.Integer) elif key == "mount/errorStatus": self._error_status = _convert_value(value, MotionMountValueType.Integer) elif key == "configuration/authentication/status": self._authentication_status = _convert_value(value, MotionMountValueType.Bytes)[0] elif key == "mac": self._mac = _convert_value(value, MotionMountValueType.Bytes) elif key == "configuration/name": self._name = _convert_value(value, MotionMountValueType.String) async def _reader(self, reader: asyncio.StreamReader) -> None: """ Infinite loop to receive data from the MotionMount and dispatch it to waiting requests. Args: reader (asyncio.StreamReader): The stream reader for receiving data. """ while not reader.at_eof(): data = await reader.readline() if len(data) == 0: # Connection was closed if len(self._requests) > 0: # There is a request waiting, we will let that request know about the error popped = self._requests.popleft() popped.future.set_exception(NotConnectedError) await self.disconnect() break # Check to see what kind of response this is response = data.decode().strip() if response[0] == "#": try: response_value = MotionMountResponse(int(response[1:])) except ValueError: response_value = MotionMountResponse.Unknown try: request = self._requests.popleft() if response_value == MotionMountResponse.Accepted: request.future.set_result(True) else: request.future.set_exception(MotionMountResponseError(response_value)) except IndexError: # No request was waiting, only log this error print(f"Error code: {response}") else: parts = response.split("=", 1) key = parts[0].strip() value = parts[1].strip() self._update_properties(key, value) if len(self._requests) > 0 and self._requests[0].key == key: # We received the response to this request, we can pop it popped = self._requests.popleft() popped.future.set_result(value) else: for callback in self._callbacks: try: callback() except Exception as e: # TODO: How to properly let the caller know something went wrong? print(f"Exception during notification: {e}") python-MotionMount-2.3.1/src/motionmount/py.typed000066400000000000000000000000001476007664700222110ustar00rootroot00000000000000