diff --git a/README.md b/README.md index 092fe2c..9a6d420 100644 --- a/README.md +++ b/README.md @@ -248,6 +248,79 @@ post-callbacks. These callbacks enable operations to be executed **before** or **after** the method invocation, allowing for user-defined interactions with the data model. +## Connectors + +Connectors keep the public node API protocol-agnostic. A variable can still be +read with `read()`, written with `write()`, and subscribed to regardless of the +remote backend. + +### OPC UA + +OPC UA variables map to OPC UA nodes. Reads and writes are performed on the +OPC UA nodes, and method calls are performed on the OPC UA method nodes. + +```yaml +connectors: + - !!OpcUaConnector + name: "opc_ua_server" + ip: "127.0.0.1" + port: 4840 + +root: + !!FolderNode + name: "Objects" + connector_name: "opc_ua_server" + children: + - !!NumericalVariableNode + name: "Temperature" + default_value: 20.0 + - !!BooleanVariableNode + name: "StartCommand" + default_value: false + remote_resource_spec: + !!OpcUaRemoteResourceSpec + node_id: "ns=2;s=StartCommand" +``` + +### MQTT + +MQTT variables map to MQTT topics. Reads return the last received payload for +the subscription topic, writes publish the value to the publish topic, +and method calls are not supported by the MQTT connector yet. + +```yaml +connectors: + - !!MqttConnector + name: "mqtt_broker" + ip: "127.0.0.1" + port: 1883 + topic_prefix: "machines/boiler-1" + payload_codec: "string" + +root: + !!FolderNode + name: "Objects" + connector_name: "mqtt_broker" + children: + - !!NumericalVariableNode + name: "Temperature" + default_value: 20.0 + - !!BooleanVariableNode + name: "StartCommand" + default_value: false + remote_resource_spec: + !!MqttRemoteResourceSpec + subscribe_topic: "plant/line-1/start/state" + publish_topic: "plant/line-1/start/set" +``` + +Supported MQTT payload codecs are `string`, `json`, and `msgpack`. The default +`string` codec encodes strings, integers, floats, and booleans as UTF-8 scalar +payloads and uses the data model node type when deserializing subscribed values. +Python code can also pass `payload_serializer` and `payload_deserializer` to +`MqttConnector` for custom payload formats. Topic wildcards are not supported +in remote resource specs. + ## Protocol Manager The protocol manager is a component that acts as an interface between the data model diff --git a/examples/mqtt/basic_mqtt.yml b/examples/mqtt/basic_mqtt.yml new file mode 100644 index 0000000..8bb1573 --- /dev/null +++ b/examples/mqtt/basic_mqtt.yml @@ -0,0 +1,27 @@ +name: "boiler" +machine_category: "boiler_cat" +machine_type: "boiler_type" +machine_model: "boiler_model" +description: "Boiler data model with MQTT-backed variables" +connectors: + - !!MqttConnector + name: "mqtt_broker" + ip: "127.0.0.1" + port: 1883 + topic_prefix: "machines/boiler-1" + payload_codec: "string" +root: + !!FolderNode + name: "Objects" + connector_name: "mqtt_broker" + children: + - !!NumericalVariableNode + name: "Temperature" + default_value: 20.0 + - !!BooleanVariableNode + name: "StartCommand" + default_value: false + remote_resource_spec: + !!MqttRemoteResourceSpec + subscribe_topic: "plant/line-1/start/state" + publish_topic: "plant/line-1/start/set" diff --git a/examples/opcua/connect.py b/examples/opcua/connect.py index 4e14a8b..ec7fa55 100644 --- a/examples/opcua/connect.py +++ b/examples/opcua/connect.py @@ -18,6 +18,7 @@ AbstractConnector, SubscriptionArguments, ) +from machine_data_model.nodes.connectors.remote_resource import RemoteResource from machine_data_model.nodes.method_node import MethodNode from machine_data_model.nodes.subscription.variable_subscription import ( DataChangeSubscription, @@ -70,12 +71,13 @@ def read_and_write_variable_node_using_connector( connector: AbstractConnector, node_path: str ): """Uses the connector to read and write the variable.""" + resource = RemoteResource(node_path) print(f"Reading node '{node_path}'...") - current_value = connector.read_node_value(node_path) + current_value = connector.read_node_value(resource) print("current value:", current_value) - connector.write_node_value(node_path, current_value + 10) + connector.write_node_value(resource, current_value + 10) print("wrote the previous value + 10") - new_value = connector.read_node_value(node_path) + new_value = connector.read_node_value(resource) print(f"Read node '{node_path}' again, its current value is:", new_value) @@ -147,7 +149,9 @@ def subscribe_to_variable_node_using_connector( connector: AbstractConnector, node_path: str ): """Subscribe to node changes using the connector.""" - connector.subscribe_to_node_changes(node_path, my_remote_callback) + connector.subscribe_to_node_changes( + RemoteResource(node_path), my_remote_callback + ) def main() -> None: diff --git a/machine_data_model/builder/data_model_builder.py b/machine_data_model/builder/data_model_builder.py index f3f0b76..d029291 100644 --- a/machine_data_model/builder/data_model_builder.py +++ b/machine_data_model/builder/data_model_builder.py @@ -29,6 +29,10 @@ from machine_data_model.nodes.composite_method.composite_method_node import ( CompositeMethodNode, ) +from machine_data_model.nodes.connectors.mqtt import ( + MqttConnector, + MqttRemoteResourceSpec, +) from machine_data_model.nodes.connectors.opcua.opcua_connector import ( OpcuaConnector, OpcuaRemoteResourceSpec, @@ -640,6 +644,50 @@ def _get_opcua_remote_resource_spec( return OpcuaRemoteResourceSpec(**kwargs) +def _get_mqtt_connector_node( + loader: yaml.FullLoader, node: yaml.MappingNode +) -> MqttConnector: + """Construct an MQTT Connector from a yaml node.""" + data = loader.construct_mapping(node, deep=True) + default_kwargs = { + "name": None, + "ip": "127.0.0.1", + "ip_env_var": None, + "port": 1883, + "port_env_var": None, + "username": None, + "username_env_var": None, + "password": None, + "password_env_var": None, + "client_id": None, + "topic_prefix": None, + "keepalive": 60, + "qos": 0, + "retain": False, + "payload_codec": "string", + } + kwargs = _build_kwargs(data, default_kwargs) + return MqttConnector(**kwargs) + + +def _get_mqtt_remote_resource_spec( + loader: yaml.FullLoader, node: yaml.MappingNode +) -> MqttRemoteResourceSpec: + """Construct an MQTT remote resource spec from a yaml node.""" + data = loader.construct_mapping(node, deep=True) + default_kwargs = { + "remote_path": None, + "topic": None, + "topic_prefix": None, + "publish_topic": None, + "subscribe_topic": None, + "qos": None, + "retain": None, + } + kwargs = _build_kwargs(data, default_kwargs) + return MqttRemoteResourceSpec(**kwargs) + + def _register_yaml_constructors() -> None: """Register all YAML constructors for data model building.""" constructors = { @@ -661,6 +709,8 @@ def _register_yaml_constructors() -> None: WaitRemoteEventNode: _get_wait_remote_event_node, OpcuaConnector: _get_opcua_connector_node, OpcuaRemoteResourceSpec: _get_opcua_remote_resource_spec, + MqttConnector: _get_mqtt_connector_node, + MqttRemoteResourceSpec: _get_mqtt_remote_resource_spec, } for node, constructor in constructors.items(): diff --git a/machine_data_model/builder/data_model_dumper.py b/machine_data_model/builder/data_model_dumper.py index 0487168..b5d88b9 100644 --- a/machine_data_model/builder/data_model_dumper.py +++ b/machine_data_model/builder/data_model_dumper.py @@ -27,6 +27,10 @@ from machine_data_model.nodes.composite_method.composite_method_node import ( CompositeMethodNode, ) +from machine_data_model.nodes.connectors.mqtt import ( + MqttConnector, + MqttRemoteResourceSpec, +) from machine_data_model.nodes.connectors.opcua.opcua_connector import ( OpcuaConnector, OpcuaRemoteResourceSpec, @@ -89,14 +93,7 @@ def _opcua_connector_representer( """ connector_dict: dict[str, Any] = {"name": connector.name} - if connector.ip_env_var: - connector_dict["ip_env_var"] = connector.ip_env_var - else: - connector_dict["ip"] = connector.ip - if connector.port_env_var: - connector_dict["port_env_var"] = connector.port_env_var - else: - connector_dict["port"] = connector.port + connector_dict.update(connector.address_to_dict()) if connector.security_policy: connector_dict["security_policy"] = connector.security_policy if connector.client_app_uri != connector.get_default_client_app_uri(): @@ -147,6 +144,47 @@ def _get_opcua_remote_resource_spec_representer( ) +def _mqtt_connector_representer( + dumper: yaml.Dumper, connector: MqttConnector +) -> yaml.nodes.MappingNode: + """Represent an MqttConnector as a YAML mapping node.""" + connector_dict: dict[str, Any] = {"name": connector.name} + + connector_dict.update(connector.address_to_dict()) + connector_dict.update(connector.auth_to_dict()) + if connector.client_id: + connector_dict["client_id"] = connector.client_id + if connector.topic_prefix: + connector_dict["topic_prefix"] = connector.topic_prefix + if connector.keepalive != 60: + connector_dict["keepalive"] = connector.keepalive + if connector.qos != 0: + connector_dict["qos"] = connector.qos + if connector.retain: + connector_dict["retain"] = connector.retain + if connector.payload_codec != "string": + connector_dict["payload_codec"] = connector.payload_codec + + return dumper.represent_mapping( + "tag:yaml.org,2002:MqttConnector", connector_dict + ) + + +def _get_mqtt_remote_resource_spec_representer( + dumper: yaml.Dumper, spec: MqttRemoteResourceSpec +) -> yaml.nodes.MappingNode: + """Represent an MqttRemoteResourceSpec as a YAML mapping node.""" + remote_resource_spec: dict[str, Any] = {} + for key, value in spec.to_dict().items(): + if value is not None: + remote_resource_spec[key] = value + + return dumper.represent_mapping( + "tag:yaml.org,2002:MqttRemoteResourceSpec", + remote_resource_spec, + ) + + def _get_data_model_node_common_fields(node: DataModelNode) -> dict[str, Any]: """Get common fields for variable nodes. @@ -623,6 +661,8 @@ def _register_representers() -> None: DataModel: _data_model_representer, OpcuaConnector: _opcua_connector_representer, OpcuaRemoteResourceSpec: _get_opcua_remote_resource_spec_representer, + MqttConnector: _mqtt_connector_representer, + MqttRemoteResourceSpec: _get_mqtt_remote_resource_spec_representer, FolderNode: _folder_node_representer, NumericalVariableNode: _numerical_variable_node_representer, BooleanVariableNode: _boolean_variable_node_representer, diff --git a/machine_data_model/data_model.py b/machine_data_model/data_model.py index a0b1b19..0aa99d1 100644 --- a/machine_data_model/data_model.py +++ b/machine_data_model/data_model.py @@ -1,5 +1,7 @@ from collections.abc import Callable, Iterable +import logging from typing import Any +import weakref from machine_data_model.behavior.local_execution_node import LocalExecutionNode from machine_data_model.behavior.remote_execution_node import ( @@ -25,9 +27,7 @@ VariableNode, ) -from .nodes.connectors.opcua.opcua_remote_resource_spec import ( - OpcuaRemoteResourceSpec, -) +_logger = logging.getLogger(__name__) class DataModel: @@ -75,28 +75,38 @@ def __init__( name="root", description="Root folder of the data model" ) ) + self._closed = False + self._connector_finalizer: weakref.finalize | None = None self._connectors: dict[str, AbstractConnector] = ( self._initialize_connectors(connectors) ) + if self._connectors: + self._connector_finalizer = weakref.finalize( + self, + DataModel._disconnect_connectors, + tuple(self._connectors.values()), + ) - # hashmap for fast access to nodes by id - self._nodes: dict[str, DataModelNode] = {} - self._register_nodes(self._root) - - # set up the connector for each node - # todo: this can be optimized by setting - # the connectors while registering the nodes - self._setup_inheritable_specs(self._root) - for node in self._nodes.values(): - self._set_node_connector(node) - # subscribe to all the variable changes - # > if it is an object, skip it: the subscription is done on the - # properties - if isinstance(node, VariableNode) and not isinstance( - node, ObjectVariableNode - ): - node.subscribe_to_remote_changes() + try: + # hashmap for fast access to nodes by id + self._nodes: dict[str, DataModelNode] = {} + self._register_nodes(self._root) + + # set up the connector and resolved remote resource for each node + self._setup_inheritable_specs(self._root) + for node in self._nodes.values(): + self._set_node_connector(node) + # subscribe to all the variable changes + # > if it is an object, skip it: the subscription is done on the + # properties + if isinstance(node, VariableNode) and not isinstance( + node, ObjectVariableNode + ): + node.subscribe_to_remote_changes() + except Exception: + self._cleanup_connectors(self._connectors.values()) + raise def _initialize_connectors( self, connectors: list[AbstractConnector] | None @@ -128,7 +138,7 @@ def _initialize_connectors( for connector in connectors: if connector.name is None: self._cleanup_connectors(connectors) - raise Exception( + raise ValueError( "At least one connector doesn't have the name attribute " "defined" ) @@ -136,7 +146,7 @@ def _initialize_connectors( # check if the name/identifier is unique if connectors_dict.get(connector.name) is not None: self._cleanup_connectors(connectors) - raise Exception( + raise ValueError( f"There are at least two connectors with the same " f"name/identifier: {connector.name}." ) @@ -147,7 +157,7 @@ def _initialize_connectors( # connectors if not connection_successful: self._cleanup_connectors(connectors) - raise Exception( + raise ConnectionError( f"Failed to connect to the remote server using the " f"{connector.name} connector." ) @@ -178,6 +188,7 @@ def _set_node_connector(self, node: DataModelNode) -> None: node.set_remote_path(remote_path) else: node.set_remote_path(node.qualified_name) + node.configure_remote_resource() def _setup_inheritable_specs(self, root: DataModelNode) -> None: """Calls the recursive method which sets up all the nodes remote specs. @@ -200,19 +211,13 @@ def _setup_child_inherited_specs( Parent of node. Can have inheritable specs. """ if parent and parent.remote_resource_spec: - inheritable_spec = parent.remote_resource_spec.inheritable_spec() + parent_spec = parent.remote_resource_spec if node.remote_resource_spec: - node.remote_resource_spec.inherit_spec(inheritable_spec) + node.remote_resource_spec.inherit_spec( + parent_spec.inheritable_spec() + ) else: - # Only create OpcuaRemoteResourceSpec if parent has one - if isinstance(inheritable_spec, OpcuaRemoteResourceSpec): - node.remote_resource_spec = OpcuaRemoteResourceSpec( - namespace=inheritable_spec.namespace, - parent=node, - remote_path=inheritable_spec.remote_path, - parent_node_id=inheritable_spec.node_id, - ) - node.remote_resource_spec.parent = node + node.remote_resource_spec = parent_spec.clone_for_child() for child in node: self._setup_child_inherited_specs(child, node) @@ -621,6 +626,23 @@ def close_connectors(self) -> None: """Disconnect all connectors and stop their threads.""" self._cleanup_connectors(self._connectors.values()) + def close(self) -> None: + """Close the data model and release connector resources.""" + self.close_connectors() + + def __enter__(self) -> "DataModel": + """Enter a context manager for deterministic connector cleanup.""" + return self + + def __exit__( + self, + _exc_type: type[BaseException] | None, + _exc_value: BaseException | None, + _traceback: Any, + ) -> None: + """Exit a context manager and close connectors.""" + self.close() + def _cleanup_connectors( self, connectors: Iterable[AbstractConnector] ) -> None: @@ -630,8 +652,30 @@ def _cleanup_connectors( connectors: Connectors that have resources to clean up. """ + if self._closed: + return + self._closed = True + if ( + self._connector_finalizer is not None + and self._connector_finalizer.alive + ): + self._connector_finalizer.detach() + self._disconnect_connectors(connectors) + + @staticmethod + def _disconnect_connectors( + connectors: Iterable[AbstractConnector], + ) -> None: + """Disconnect connectors without requiring a live DataModel object.""" for connector in connectors: - connector.disconnect() + try: + connector.disconnect() + except Exception as exp: + _logger.error( + "Failed to disconnect connector %r during cleanup", + connector.name, + ) + _logger.error(exp) def __str__(self) -> str: return ( diff --git a/machine_data_model/nodes/connectors/__init__.py b/machine_data_model/nodes/connectors/__init__.py index e69de29..256f10e 100644 --- a/machine_data_model/nodes/connectors/__init__.py +++ b/machine_data_model/nodes/connectors/__init__.py @@ -0,0 +1,5 @@ +"""Connector abstractions and shared connector types.""" + +from machine_data_model.nodes.connectors.remote_resource import RemoteResource + +__all__ = ["RemoteResource"] diff --git a/machine_data_model/nodes/connectors/abstract_async_connector.py b/machine_data_model/nodes/connectors/abstract_async_connector.py index 108b761..6dce698 100644 --- a/machine_data_model/nodes/connectors/abstract_async_connector.py +++ b/machine_data_model/nodes/connectors/abstract_async_connector.py @@ -10,24 +10,33 @@ from asyncio import AbstractEventLoop from collections.abc import Callable, Coroutine from concurrent.futures import Future +from concurrent.futures import TimeoutError as FutureTimeoutError import logging +import threading from threading import Thread from typing import Any, TypeVar from typing_extensions import override -from machine_data_model.nodes.connectors.abstract_remote_resource_spec import ( - AbstractRemoteResourceSpec, -) - from .abstract_connector import AbstractConnector, SubscriptionArguments +from .remote_resource import RemoteResource TaskReturnType = TypeVar("TaskReturnType") _logger = logging.getLogger(__name__) -def create_event_loop_thread() -> AbstractEventLoop: +class _ReentrantDispatchState(threading.local): + """Per-thread flag, ``False`` on every thread until a dispatch sets it. + + It marks threads running a user callback while the connector's event loop + is blocked dispatching it, so re-entrant calls avoid that busy loop. + """ + + active: bool = False + + +def create_event_loop_thread() -> tuple[AbstractEventLoop, Thread]: """Creates a thread with an asyncio event loop. The loop can then be used to execute the async tasks inside the thread. @@ -36,8 +45,8 @@ def create_event_loop_thread() -> AbstractEventLoop: https://gist.github.com/dmfigol/3e7d5b84a16d076df02baa9f53271058?permalink_comment_id=5553292#gistcomment-5553292 Returns: - AbstractEventLoop: - Asyncio event loop which will be run in a separate thread. + tuple[AbstractEventLoop, Thread]: + Asyncio event loop and thread running it. """ def start_background_loop(loop: AbstractEventLoop) -> None: @@ -57,7 +66,7 @@ def start_background_loop(loop: AbstractEventLoop) -> None: ) thread.start() _logger.debug("Created thread and its event loop") - return event_loop + return event_loop, thread def run_coroutine_in_thread( @@ -141,9 +150,15 @@ def __init__( password_env_var=password_env_var, ) - self._event_loop = ( - create_event_loop_thread() if event_loop is None else event_loop - ) + self._owns_event_loop = event_loop is None + self._event_loop_thread: Thread | None = None + self._reentrant_local = _ReentrantDispatchState() + if event_loop is None: + self._event_loop, self._event_loop_thread = ( + create_event_loop_thread() + ) + else: + self._event_loop = event_loop @override def connect(self) -> bool: @@ -153,8 +168,18 @@ def connect(self) -> bool: bool: True if the client is connected to the server. """ + self._ensure_owned_event_loop() return self._handle_task(self._async_connect()) + def _ensure_owned_event_loop(self) -> None: + """Create a fresh private event loop when reconnecting.""" + if not self._owns_event_loop: + return + if self._event_loop.is_closed(): + self._event_loop, self._event_loop_thread = ( + create_event_loop_thread() + ) + @abstractmethod async def _async_connect(self) -> bool: """Asynchronous code which uses the client to connect to the server. @@ -172,10 +197,25 @@ def disconnect(self) -> bool: bool: True if the client is disconnected from the server. """ - res = self._handle_task(self._async_disconnect()) - self._event_loop.stop() + if self._event_loop.is_closed(): + return True + try: + res = self._handle_task(self._async_disconnect()) + finally: + self._stop_owned_event_loop() return res + def _stop_owned_event_loop(self) -> None: + """Stop and close the private event loop when this connector owns it.""" + if not self._owns_event_loop or self._event_loop.is_closed(): + return + if self._event_loop.is_running(): + self._event_loop.call_soon_threadsafe(self._event_loop.stop) + if self._event_loop_thread is not None: + self._event_loop_thread.join(timeout=5) + if not self._event_loop.is_running(): + self._event_loop.close() + @abstractmethod async def _async_disconnect(self) -> bool: """Asynchronously disconnects the client from the remote resource. @@ -186,79 +226,53 @@ async def _async_disconnect(self) -> bool: """ @override - def _get_remote_node( - self, - path: str | None = None, - remote_resource_spec: AbstractRemoteResourceSpec | None = None, - ) -> Any: - """Retrieves and returns a node from the remote resource. + def _get_remote_resource(self, resource: RemoteResource) -> Any: + """Resolve and return a protocol-specific remote resource handle. Args: - path (str): - Node's path. - remote_resource_spec (AbstractRemoteResourceSpec | None): - Protocol-specific properties for remote nodes. + resource: + Resolved remote resource reference. Returns: Any: - Node with the given path. + Protocol-specific resource handle. """ - return self._handle_task( - self._async_get_remote_node(path, remote_resource_spec) - ) + return self._handle_task(self._async_get_remote_resource(resource)) @abstractmethod - async def _async_get_remote_node( - self, - path: str | None = None, - remote_resource_spec: AbstractRemoteResourceSpec | None = None, - ) -> Any: - """Asynchronously retrieves and returns a node from the remote resource. + async def _async_get_remote_resource(self, resource: RemoteResource) -> Any: + """Asynchronously resolve a protocol-specific remote resource handle. Args: - path (str): - Node's path. - remote_resource_spec (AbstractRemoteResourceSpec | None): - Protocol-specific properties for remote nodes. + resource: + Resolved remote resource reference. Returns: Any: - Node with the given path. + Protocol-specific resource handle. """ @override - def read_node_value( - self, - path: str, - remote_resource_spec: AbstractRemoteResourceSpec | None = None, - ) -> Any: - """Reatrieves and returns a node's value. + def read_node_value(self, resource: RemoteResource) -> Any: + """Retrieve and return a node's value. Args: - path (str): - Node's path. - remote_resource_spec (AbstractRemoteResourceSpec | None): - Protocol-specific properties for remote nodes. + resource: + Resolved remote resource reference. Returns: Any: Node's value. """ - return self._handle_task( - self._async_read_node_value(path, remote_resource_spec) - ) + return self._handle_task(self._async_read_node_value(resource)) @abstractmethod - async def _async_read_node_value( - self, path: str, remote_resource_spec: AbstractRemoteResourceSpec | None - ) -> Any: + async def _async_read_node_value(self, resource: RemoteResource) -> Any: """Asynchronous code which reads a node's value. Args: - path (str): - Node's path. - remote_resource_spec (AbstractRemoteResourceSpec | None): - Protocol-specific properties for remote nodes. + resource: + Resolved remote resource reference. Returns: Any: @@ -268,44 +282,36 @@ async def _async_read_node_value( @override def write_node_value( self, - path: str, + resource: RemoteResource, value: Any, - remote_resource_spec: AbstractRemoteResourceSpec | None = None, ) -> bool: """Writes a variable node. Args: - path (str): - Node's path. - value (Any): + resource: + Resolved remote resource reference. + value: New value to write. - remote_resource_spec (AbstractRemoteResourceSpec | None): - Protocol-specific properties for remote nodes. Returns: bool: True if the operation was successful, False otherwise. """ - return self._handle_task( - self._async_write_node_value(path, value, remote_resource_spec) - ) + return self._handle_task(self._async_write_node_value(resource, value)) @abstractmethod async def _async_write_node_value( self, - path: str, + resource: RemoteResource, value: Any, - remote_resource_spec: AbstractRemoteResourceSpec | None, ) -> bool: """Asynchronous code which writes a variable node. Args: - path (str): - Node's path. - value (Any): + resource: + Resolved remote resource reference. + value: New value to write. - remote_resource_spec (AbstractRemoteResourceSpec | None): - Protocol-specific properties for remote nodes. Returns: bool: @@ -315,44 +321,38 @@ async def _async_write_node_value( @override def call_node_as_method( self, - path: str, + resource: RemoteResource, kwargs: dict[str, Any], - remote_resource_spec: AbstractRemoteResourceSpec | None = None, ) -> Any: - """Invokes the method with path using . + """Invoke the remote method using ``kwargs``. Args: - path (str): - Node/method path. - kwargs (dict[str, Any]): + resource: + Resolved remote resource reference. + kwargs: Method arguments expressed as key/name - value pairs - remote_resource_spec (AbstractRemoteResourceSpec | None): - Protocol-specific properties for remote nodes. Returns: Any: Method's returned value. """ return self._handle_task( - self._async_call_node_as_method(path, kwargs, remote_resource_spec) + self._async_call_node_as_method(resource, kwargs) ) @abstractmethod async def _async_call_node_as_method( self, - path: str, + resource: RemoteResource, kwargs: dict[str, Any], - remote_resource_spec: AbstractRemoteResourceSpec | None, ) -> Any: - """Asynchronously invokes the method with path using . + """Asynchronously invoke the remote method using ``kwargs``. Args: - path (str): - Node/method path. - kwargs (dict[str, Any]): + resource: + Resolved remote resource reference. + kwargs: Method arguments expressed as key/name - value pairs. - remote_resource_spec (AbstractRemoteResourceSpec | None): - Protocol-specific properties for remote nodes. Returns: Any: @@ -362,18 +362,15 @@ async def _async_call_node_as_method( @override def subscribe_to_node_changes( self, - path: str, + resource: RemoteResource, callback: Callable[[Any, SubscriptionArguments], None], - remote_resource_spec: AbstractRemoteResourceSpec | None = None, ) -> int: """Subscribes to remote node changes. Args: - path (str): - Node path. - remote_resource_spec (AbstractRemoteResourceSpec | None): - Protocol-specific properties for remote nodes. - callback (Callable[[Any, SubscriptionArguments], None]): + resource: + Resolved remote resource reference. + callback: Subscription's callback. The first parameter is the new value, while the second parameter is additional data that is protocol dependent. @@ -384,27 +381,23 @@ def subscribe_to_node_changes( """ return self._handle_task( self._async_subscribe_to_node_changes( - path, - remote_resource_spec, - callback, # Correct order + resource, + callback, ) ) @abstractmethod async def _async_subscribe_to_node_changes( self, - path: str, - remote_resource_spec: AbstractRemoteResourceSpec | None, + resource: RemoteResource, callback: Callable[[Any, SubscriptionArguments], None], ) -> int: """Asynchronously subscribes to remote node changes. Args: - path (str): - Node path. - remote_resource_spec (AbstractRemoteResourceSpec | None): - Protocol-specific properties for remote nodes. - callback (Callable[[Any, SubscriptionArguments], None]): + resource: + Resolved remote resource reference. + callback: Subscription's callback. The first parameter is the new value, while the second parameter is additional data that is protocol dependent. @@ -414,22 +407,87 @@ async def _async_subscribe_to_node_changes( Handler code which can be used to unsubscribe from new events. """ + @override + def unsubscribe_from_node_changes(self, handle: int) -> bool: + """Unsubscribes from remote node changes. + + Args: + handle: + Handler code returned by ``subscribe_to_node_changes``. + + Returns: + bool: + True if the subscription was found and removed. + """ + return self._handle_task( + self._async_unsubscribe_from_node_changes(handle) + ) + + async def _async_unsubscribe_from_node_changes(self, handle: int) -> bool: + """Asynchronously unsubscribes from remote node changes. + + Connectors that support subscriptions override this method. The default + keeps connectors that never expose subscriptions concrete. + + Args: + handle: + Handler code returned by ``subscribe_to_node_changes``. + + Returns: + bool: + True if the subscription was found and removed. + """ + raise NotImplementedError( + f"'{self.name}' connector does not support unsubscribe" + ) + + def _set_reentrant_dispatch(self, active: bool) -> None: + """Flag the current thread as running a re-entrant callback dispatch. + + While the flag is set on a thread, ``_handle_task`` calls issued from + that thread run on a private event loop instead of the connector's main + loop, which is blocked waiting for the callback to finish. + """ + self._reentrant_local.active = active + def _handle_task( - self, task: Coroutine[None, None, TaskReturnType] + self, + task: Coroutine[None, None, TaskReturnType], + timeout: float | None = None, ) -> TaskReturnType: """Run a task in the thread, wait for the result and return it. Args: task (Coroutine[None, None, TaskReturnType]): Coroutine which will be executed in the connector's thread. + timeout (float | None): + Maximum number of seconds to wait for the result. If None, + wait indefinitely. Returns: TaskReturnType: Coroutine result. """ + if self._event_loop.is_closed(): + task.close() + raise RuntimeError( + f"Cannot run task using '{self.name}' connector: the event " + "loop is closed" + ) + if self._reentrant_local.active: + # The call originates from a user callback that is running while the + # connector's event loop is blocked dispatching it. Submitting to + # that loop would deadlock, so run the coroutine on a private loop. + if timeout is not None: + task = asyncio.wait_for(task, timeout) + return asyncio.run(task) _logger.debug(f"Running task {task} using '{self.name}' connector") res = run_coroutine_in_thread(self._event_loop, task) - output = res.result() + try: + output = res.result(timeout=timeout) + except FutureTimeoutError: + res.cancel() + raise _logger.debug( f"Ran task {task} using '{self.name}' connector. " f"Its result is {output!r}" diff --git a/machine_data_model/nodes/connectors/abstract_connector.py b/machine_data_model/nodes/connectors/abstract_connector.py index 83da9a3..f792db9 100644 --- a/machine_data_model/nodes/connectors/abstract_connector.py +++ b/machine_data_model/nodes/connectors/abstract_connector.py @@ -13,19 +13,13 @@ from abc import ABC, abstractmethod from collections.abc import Callable from dataclasses import dataclass -import logging import os from typing import Any, TypeVar import uuid -from .abstract_remote_resource_spec import ( - AbstractRemoteResourceSpec, -) +from .remote_resource import RemoteResource -TaskReturnType = TypeVar("TaskReturnType") -YamlEntryType = int | str | float - -_logger = logging.getLogger(__name__) +ConnectorConfigValue = TypeVar("ConnectorConfigValue", int, str, float) @dataclass(frozen=True) @@ -83,40 +77,34 @@ def __init__( ip_value = self._get_yaml_entry_or_env_var_value( "ip", str, ip, ip_env_var, env_var_overrides_yaml=True ) - assert isinstance(ip_value, str | None), "ip must be a str or None" - self.ip: str | None = ip_value + self.ip = ip_value self.port_env_var: str | None = port_env_var port_value = self._get_yaml_entry_or_env_var_value( "port", int, port, port_env_var, env_var_overrides_yaml=True ) - assert isinstance(port_value, int | None), "port must be a int or None" self.port = port_value + self.username_env_var: str | None = username_env_var username_value = self._get_yaml_entry_or_env_var_value( "username", str, username, username_env_var ) - assert isinstance( - username_value, str | None - ), "username must be a str or None" self.username = username_value + self.password_env_var: str | None = password_env_var password_value = self._get_yaml_entry_or_env_var_value( "password", str, password, password_env_var ) - assert isinstance( - password_value, str | None - ), "password must be a str or None" self.password = password_value def _get_yaml_entry_or_env_var_value( self, yaml_entry_name: str, - yaml_entry_type: type[YamlEntryType], - yaml_entry: YamlEntryType | None, + yaml_entry_type: type[ConnectorConfigValue], + yaml_entry: ConnectorConfigValue | None, env_var: str | None, env_var_overrides_yaml: bool = False, - ) -> YamlEntryType | None: + ) -> ConnectorConfigValue | None: """Returns the value of a yaml entry or an environment variable. Returns the content of the env_var environment variable when set, @@ -173,6 +161,40 @@ def _get_yaml_entry_or_env_var_value( value = yaml_entry_type(value) return value + def address_to_dict(self) -> dict[str, Any]: + """Serialize host/port, preserving environment-variable indirection. + + An ``*_env_var`` reference is emitted instead of its resolved value so a + dump/reload stays env-driven. + """ + data: dict[str, Any] = {} + if self.ip_env_var: + data["ip_env_var"] = self.ip_env_var + else: + data["ip"] = self.ip + if self.port_env_var: + data["port_env_var"] = self.port_env_var + else: + data["port"] = self.port + return data + + def auth_to_dict(self) -> dict[str, Any]: + """Serialize credentials, preserving environment-variable indirection. + + An ``*_env_var`` reference is emitted instead of its resolved value, and + unset credentials are omitted. + """ + data: dict[str, Any] = {} + if self.username_env_var: + data["username_env_var"] = self.username_env_var + elif self.username: + data["username"] = self.username + if self.password_env_var: + data["password_env_var"] = self.password_env_var + elif self.password: + data["password"] = self.password + return data + @abstractmethod def connect(self) -> bool: """Connect to the server. @@ -192,39 +214,28 @@ def disconnect(self) -> bool: """ @abstractmethod - def _get_remote_node( - self, - path: str | None = None, - remote_resource_spec: AbstractRemoteResourceSpec | None = None, - ) -> Any: - """Try to retrieve the node from the server. + def _get_remote_resource(self, resource: RemoteResource) -> Any: + """Resolve the protocol-specific object behind a remote resource. - The node's type depends on the library used to interact with the server. + The returned type depends on the connector implementation. It can be a + remote node, a topic, an address, or any other protocol handle. Args: - path (str | None): - Node's path. - remote_resource_spec (AbstractRemoteResourceSpec | None): - Protocol-specific properties for remote nodes. + resource: + Resolved remote resource reference. Returns: Any: - The node retrieved from the server. + The protocol-specific resource handle. """ @abstractmethod - def read_node_value( - self, - path: str, - remote_resource_spec: AbstractRemoteResourceSpec | None = None, - ) -> Any: + def read_node_value(self, resource: RemoteResource) -> Any: """Retrieve and return a node's value. Args: - path: - Node's path. - remote_resource_spec: - Protocol-specific properties for remote nodes. + resource: + Resolved remote resource reference. Returns: Any: @@ -234,19 +245,16 @@ def read_node_value( @abstractmethod def write_node_value( self, - path: str, + resource: RemoteResource, value: Any, - remote_resource_spec: AbstractRemoteResourceSpec | None = None, ) -> bool: """Write a variable node. Args: - path (str): - Node's path. - value (Any): + resource: + Resolved remote resource reference. + value: New value to write. - remote_resource_spec: - Protocol-specific properties for remote nodes. Returns: bool: @@ -256,31 +264,27 @@ def write_node_value( @abstractmethod def call_node_as_method( self, - path: str, + resource: RemoteResource, kwargs: dict[str, Any], - remote_resource_spec: AbstractRemoteResourceSpec | None = None, - ) -> dict[str, Any]: - """Calls the method at path with as its arguments. + ) -> Any: + """Call a remote method with ``kwargs`` as its arguments. Args: - path (str): - Node/method path. - kwargs (dict[str, Any]): + resource: + Resolved remote resource reference. + kwargs: Method arguments expressed as key/name - value pairs. - remote_resource_spec: - Protocol-specific properties for remote nodes. Returns: - dict[str, Any]: - Dict of results in the form of name - value pairs. + Any: + Method's returned value. """ @abstractmethod def subscribe_to_node_changes( self, - path: str, + resource: RemoteResource, callback: Callable[[Any, SubscriptionArguments], None], - remote_resource_spec: AbstractRemoteResourceSpec | None = None, ) -> int: """Subscribes to remote node changes. @@ -292,16 +296,30 @@ def subscribe_to_node_changes( Connector's protocol/implementation Args: - path (str): - Node's path. - callback (Callable[[Any, SubscriptionArguments], None]): + resource: + Resolved remote resource reference. + callback: Subscription's callback. The first parameter is the new value, while the second parameter is additional data that is protocol dependent. - remote_resource_spec: - Protocol-specific properties for remote nodes. Returns: int: Handler code which can be used to unsubscribe from new events. """ + + @abstractmethod + def unsubscribe_from_node_changes(self, handle: int) -> bool: + """Unsubscribes from remote node changes. + + Consumes the handle returned by ``subscribe_to_node_changes`` and stops + delivering updates to that subscription's callback. + + Args: + handle: + Handler code returned by ``subscribe_to_node_changes``. + + Returns: + bool: + True if the subscription was found and removed. + """ diff --git a/machine_data_model/nodes/connectors/abstract_remote_resource_spec.py b/machine_data_model/nodes/connectors/abstract_remote_resource_spec.py index 30477bf..40f5899 100644 --- a/machine_data_model/nodes/connectors/abstract_remote_resource_spec.py +++ b/machine_data_model/nodes/connectors/abstract_remote_resource_spec.py @@ -6,7 +6,7 @@ """ from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Union +from typing import TYPE_CHECKING if TYPE_CHECKING: from machine_data_model.nodes.data_model_node import DataModelNode @@ -17,29 +17,16 @@ class AbstractRemoteResourceSpec(ABC): def __init__( self, - parent: Union["DataModelNode", None], remote_path: str | None = None, ): """Constructor. Args: - parent (DataModelNode | None): - Node which owns these properties. remote_path (str | None, optional): Node's remote path. """ - self.parent = parent self.remote_path = remote_path - def has_parent(self) -> bool: - """Returns whether this spec has a parent node defined. - - Returns: - bool: - True if a parent node is defined, False otherwise. - """ - return self.parent is not None - def has_path(self) -> bool: """Returns whether this spec has a remote path defined. @@ -62,6 +49,18 @@ def inheritable_spec(self) -> "AbstractRemoteResourceSpec": """ pass + def clone_for_child(self) -> "AbstractRemoteResourceSpec": + """Create a compatible spec for a child node. + + The default implementation uses the inheritable part of the current + spec. + + Returns: + AbstractRemoteResourceSpec: + New spec compatible with the current spec. + """ + return self.inheritable_spec() + @abstractmethod def inherit_spec(self, parent: "AbstractRemoteResourceSpec") -> None: """Inherits properties from another spec. @@ -73,9 +72,17 @@ def inherit_spec(self, parent: "AbstractRemoteResourceSpec") -> None: pass @abstractmethod - def get_remote_path(self) -> str | None: + def get_remote_path( + self, + node: "DataModelNode | None" = None, + ) -> str | None: """Returns the remote path of this resource spec. + Args: + node: + Optional data model node context used by specs that derive a + path from the model hierarchy. + Returns: str | None: Remote path if defined, None otherwise. diff --git a/machine_data_model/nodes/connectors/mqtt/__init__.py b/machine_data_model/nodes/connectors/mqtt/__init__.py new file mode 100644 index 0000000..638e2e7 --- /dev/null +++ b/machine_data_model/nodes/connectors/mqtt/__init__.py @@ -0,0 +1,51 @@ +"""MQTT connector module.""" + +from machine_data_model.nodes.connectors.mqtt.mqtt_connector import ( + MqttConnector, + MqttSubscriptionArguments, +) +from machine_data_model.nodes.connectors.mqtt.mqtt_payload_codec import ( + JSON_MQTT_PAYLOAD_CODEC, + MSGPACK_MQTT_PAYLOAD_CODEC, + STRING_MQTT_PAYLOAD_CODEC, + MqttPayloadCodec, + MqttPayloadDeserializer, + MqttPayloadSerializer, + MqttSerializedPayload, + deserialize_json_payload, + deserialize_mqtt_value, + deserialize_msgpack_payload, + deserialize_string_payload, + get_mqtt_payload_codec, + normalize_mqtt_payload, + serialize_json_payload, + serialize_mqtt_value, + serialize_msgpack_payload, + serialize_string_payload, +) +from machine_data_model.nodes.connectors.mqtt.mqtt_remote_resource_spec import ( + MqttRemoteResourceSpec, +) + +__all__ = [ + "JSON_MQTT_PAYLOAD_CODEC", + "MSGPACK_MQTT_PAYLOAD_CODEC", + "STRING_MQTT_PAYLOAD_CODEC", + "MqttConnector", + "MqttPayloadCodec", + "MqttPayloadDeserializer", + "MqttPayloadSerializer", + "MqttRemoteResourceSpec", + "MqttSerializedPayload", + "MqttSubscriptionArguments", + "deserialize_json_payload", + "deserialize_mqtt_value", + "deserialize_msgpack_payload", + "deserialize_string_payload", + "get_mqtt_payload_codec", + "normalize_mqtt_payload", + "serialize_json_payload", + "serialize_mqtt_value", + "serialize_msgpack_payload", + "serialize_string_payload", +] diff --git a/machine_data_model/nodes/connectors/mqtt/mqtt_connector.py b/machine_data_model/nodes/connectors/mqtt/mqtt_connector.py new file mode 100644 index 0000000..e4884ed --- /dev/null +++ b/machine_data_model/nodes/connectors/mqtt/mqtt_connector.py @@ -0,0 +1,636 @@ +import asyncio +from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +import logging +import threading +from typing import Any + +import aiomqtt +from typing_extensions import override + +from machine_data_model.nodes.connectors.abstract_async_connector import ( + AbstractAsyncConnector, +) +from machine_data_model.nodes.connectors.abstract_connector import ( + SubscriptionArguments, +) +from machine_data_model.nodes.connectors.remote_resource import RemoteResource + +from .mqtt_payload_codec import ( + MqttPayloadCodec, + MqttPayloadDeserializer, + MqttPayloadSerializer, + get_mqtt_payload_codec, + normalize_mqtt_payload, +) +from .mqtt_remote_resource_spec import ( + MqttRemoteResourceSpec, + _join_topic, + _validate_topic, +) + +_logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class MqttSubscriptionArguments(SubscriptionArguments): + """Data returned to the MQTT subscription callback.""" + + topic: str + payload: bytes + qos: int + retain: bool + + +@dataclass +class _MqttSubscription: + """Per-subscriber state kept for a single ``subscribe_to_node_changes``.""" + + topic: str + callback: Callable[[Any, MqttSubscriptionArguments], None] + resource: RemoteResource + qos: int + + +class MqttConnector(AbstractAsyncConnector): + """Represents an MQTT client.""" + + def __init__( + self, + id: str | None = None, + name: str | None = None, + ip: str | None = "127.0.0.1", + ip_env_var: str | None = None, + port: int | None = 1883, + port_env_var: str | None = None, + username: str | None = None, + username_env_var: str | None = None, + password: str | None = None, + password_env_var: str | None = None, + client_id: str | None = None, + topic_prefix: str | None = None, + keepalive: int = 60, + qos: int = 0, + retain: bool = False, + payload_codec: str | MqttPayloadCodec = "string", + payload_serializer: MqttPayloadSerializer | None = None, + payload_deserializer: MqttPayloadDeserializer | None = None, + ) -> None: + """Initializes an MQTT client.""" + super().__init__( + id=id, + name=name, + ip=ip, + ip_env_var=ip_env_var, + port=port, + port_env_var=port_env_var, + username=username, + username_env_var=username_env_var, + password=password, + password_env_var=password_env_var, + ) + self.client_id = client_id + self.topic_prefix = topic_prefix + self.keepalive = keepalive + self.qos = qos + self.retain = retain + self._payload_codec = "" + self._payload_serializer: MqttPayloadSerializer + self._payload_deserializer: MqttPayloadDeserializer + self.payload_codec = payload_codec + if payload_serializer is not None: + self.payload_serializer = payload_serializer + if payload_deserializer is not None: + self.payload_deserializer = payload_deserializer + self.client: Any | None = None + self._client_context: Any | None = None + self._listener_task: asyncio.Task[None] | None = None + self._topic_payloads: dict[str, bytes] = {} + self._topic_callbacks: dict[str, dict[int, _MqttSubscription]] = {} + self._subscription_topics: dict[int, str] = {} + self._topic_qos: dict[str, int] = {} + self._next_subscription_id = 1 + self._callback_executor: ThreadPoolExecutor | None = None + + @property + def qos(self) -> int: + """Return the default MQTT QoS used for publishes and subscriptions.""" + return self._qos + + @qos.setter + def qos(self, value: int) -> None: + """Validate and store the default MQTT QoS.""" + if value not in (0, 1, 2): + raise ValueError("MQTT QoS must be 0, 1, or 2") + self._qos = value + + @property + def retain(self) -> bool: + """Return the default MQTT retain flag used for publishes.""" + return self._retain + + @retain.setter + def retain(self, value: bool) -> None: + """Validate and store the default MQTT retain flag.""" + if not isinstance(value, bool): + raise TypeError("MQTT retain must be a bool") + self._retain = value + + @override + async def _async_connect(self) -> bool: + """Asynchronously connects to the MQTT broker.""" + client = aiomqtt.Client( + hostname=self.ip or "127.0.0.1", + port=self.port or 1883, + username=self.username, + password=self.password, + identifier=self.client_id, + keepalive=self.keepalive, + ) + client_context_entered = False + try: + self.client = await client.__aenter__() + self._client_context = client + client_context_entered = True + self._listener_task = asyncio.create_task( + self._listen_for_messages() + ) + for topic in self._topic_callbacks: + await self.client.subscribe( + topic, + qos=self._topic_qos.get(topic, self.qos), + ) + except Exception as exp: + _logger.error( + f"Couldn't connect the '{self.name}' connector to the MQTT " + f"broker" + ) + _logger.error(exp) + if client_context_entered: + await self._close_client_context() + self.client = None + self._client_context = None + return False + _logger.debug(f"Connected the '{self.name}' connector to MQTT broker") + return True + + @override + async def _async_disconnect(self) -> bool: + """Asynchronously disconnects from the MQTT broker.""" + await self._cancel_listener_task() + self._shutdown_callback_executor() + + if self._client_context is None: + self.client = None + return True + + try: + await self._client_context.__aexit__(None, None, None) + except Exception as exp: + _logger.error(f"Couldn't disconnect '{self.name}' connector") + _logger.error(exp) + return False + finally: + self.client = None + self._client_context = None + + return True + + async def _close_client_context(self) -> None: + """Best-effort cleanup for partially connected clients.""" + await self._cancel_listener_task() + if self._client_context is None: + return + try: + await self._client_context.__aexit__(None, None, None) + except Exception as exp: + _logger.error(f"Couldn't close '{self.name}' MQTT client context") + _logger.error(exp) + + async def _cancel_listener_task(self) -> None: + """Cancel the background MQTT listener task.""" + if self._listener_task is None: + return + self._listener_task.cancel() + try: + await self._listener_task + except asyncio.CancelledError: + pass + except Exception as exp: + _logger.error( + f"MQTT listener for '{self.name}' stopped with an error" + ) + _logger.error(exp) + finally: + self._listener_task = None + + @override + async def _async_get_remote_resource(self, resource: RemoteResource) -> str: + """Resolve the MQTT subscription topic.""" + return self._resolve_subscribe_topic(resource) + + @override + async def _async_read_node_value(self, resource: RemoteResource) -> Any: + """Return the last MQTT value received for this node's topic.""" + topic = self._resolve_subscribe_topic(resource) + payload = self._topic_payloads.get(topic) + if payload is None: + return None + return self.deserialize_value(payload, resource) + + @override + async def _async_write_node_value( + self, + resource: RemoteResource, + value: Any, + ) -> bool: + """Publish a scalar value to the node's MQTT topic.""" + if self.client is None: + raise RuntimeError( + f"Couldn't publish '{resource.path}' using '{self.name}' " + "connector: the client is not connected" + ) + topic = self._resolve_publish_topic(resource) + payload = self.serialize_value(value, resource) + qos = self._resolve_qos(resource) + retain = self._resolve_retain(resource) + try: + await self.client.publish( + topic, + payload=payload, + qos=qos, + retain=retain, + ) + except Exception as exp: + _logger.error( + f"Failed to publish node '{resource.path}' to MQTT topic " + f"'{topic}'" + ) + _logger.error(exp) + return False + return True + + @override + async def _async_call_node_as_method( + self, + resource: RemoteResource, + kwargs: dict[str, Any], + ) -> Any: + """MQTT method calls are not supported yet.""" + raise NotImplementedError("MQTT connector does not support methods yet") + + @override + async def _async_subscribe_to_node_changes( + self, + resource: RemoteResource, + callback: Callable[[Any, MqttSubscriptionArguments], None], + ) -> int: + """Subscribe to MQTT messages for the node's topic.""" + if self.client is None: + raise RuntimeError( + f"Couldn't subscribe to '{resource.path}' using '{self.name}' " + "connector: the client is not connected" + ) + topic = self._resolve_subscribe_topic(resource) + subscription_id = self._next_subscription_id + self._next_subscription_id += 1 + + callbacks = self._topic_callbacks.setdefault(topic, {}) + first_subscription = len(callbacks) == 0 + requested_qos = self._resolve_qos(resource) + callbacks[subscription_id] = _MqttSubscription( + topic=topic, + callback=callback, + resource=resource, + qos=requested_qos, + ) + self._subscription_topics[subscription_id] = topic + previous_qos = self._topic_qos.get(topic) + qos = ( + requested_qos + if previous_qos is None + else max(previous_qos, requested_qos) + ) + self._topic_qos[topic] = qos + if first_subscription or qos != previous_qos: + await self.client.subscribe( + topic, + qos=qos, + ) + return subscription_id + + @override + async def _async_unsubscribe_from_node_changes(self, handle: int) -> bool: + """Remove a subscription and adjust the topic's broker state.""" + topic = self._subscription_topics.pop(handle, None) + if topic is None: + return False + callbacks = self._topic_callbacks.get(topic, {}) + callbacks.pop(handle, None) + + if callbacks: + # Other subscribers remain: re-subscribe at the new maximum QoS, + # which may be lower than before (QoS now ratchets down too). + new_qos = max(sub.qos for sub in callbacks.values()) + if new_qos != self._topic_qos.get(topic): + self._topic_qos[topic] = new_qos + if self.client is not None: + await self.client.subscribe(topic, qos=new_qos) + return True + + # Last subscriber left: drop the broker subscription and per-topic + # state instead of leaking it for the connector's lifetime. + if self.client is not None: + await self.client.unsubscribe(topic) + self._topic_callbacks.pop(topic, None) + self._topic_qos.pop(topic, None) + self._topic_payloads.pop(topic, None) + return True + + async def _listen_for_messages(self) -> None: + """Consume MQTT messages and dispatch callbacks.""" + if self.client is None or not isinstance(self.client, aiomqtt.Client): + return + + messages = self.client.messages + if callable(messages): + messages = messages() + + try: + async for message in messages: + self._handle_message(message) + except asyncio.CancelledError: + raise + except Exception as exp: + _logger.error(f"MQTT listener for '{self.name}' failed") + _logger.error(exp) + + def _handle_message(self, message: Any) -> None: + """Decode one MQTT message and notify registered callbacks.""" + topic = self._message_topic(message) + payload = self._message_payload(message) + self._topic_payloads[topic] = payload + other = MqttSubscriptionArguments( + topic=topic, + payload=payload, + qos=int(getattr(message, "qos", 0)), + retain=bool(getattr(message, "retain", False)), + ) + subscriptions = list(self._topic_callbacks.get(topic, {}).values()) + self._dispatch_subscriptions(subscriptions, payload, other, topic) + + def _dispatch_subscriptions( + self, + subscriptions: list[_MqttSubscription], + payload: bytes, + other: MqttSubscriptionArguments, + topic: str, + ) -> None: + """Run subscription callbacks, off the event loop when necessary.""" + if not subscriptions: + return + if threading.current_thread() is self._event_loop_thread: + # The listener runs on the event loop. Dispatch callbacks on a + # worker thread so a callback may safely re-enter the connector + # (read/write) without deadlocking the loop, and wait for them to + # finish so delivery is observable to the caller. + executor = self._ensure_callback_executor() + future = executor.submit( + self._run_subscriptions, subscriptions, payload, other, True + ) + future.result() + else: + self._run_subscriptions(subscriptions, payload, other, False) + + def _run_subscriptions( + self, + subscriptions: list[_MqttSubscription], + payload: bytes, + other: MqttSubscriptionArguments, + reentrant: bool, + ) -> None: + """Decode per subscriber and invoke each callback.""" + if reentrant: + self._set_reentrant_dispatch(True) + try: + for sub in subscriptions: + try: + value = self.deserialize_value(payload, sub.resource) + except Exception as exp: + _logger.error( + "Failed to deserialize MQTT payload for topic " + f"'{sub.topic}'" + ) + _logger.error(exp) + continue + try: + sub.callback(value, other) + except Exception as exp: + _logger.error( + "MQTT subscription callback for topic " + f"'{sub.topic}' failed" + ) + _logger.error(exp) + finally: + if reentrant: + self._set_reentrant_dispatch(False) + + def _ensure_callback_executor(self) -> ThreadPoolExecutor: + """Return the single-worker executor used to dispatch callbacks.""" + if self._callback_executor is None: + self._callback_executor = ThreadPoolExecutor( + max_workers=1, + thread_name_prefix=f"mqtt-callbacks-{self.name}", + ) + return self._callback_executor + + def _shutdown_callback_executor(self) -> None: + """Tear down the callback dispatch executor, if it was created.""" + if self._callback_executor is not None: + self._callback_executor.shutdown(wait=False) + self._callback_executor = None + + def _message_topic(self, message: Any) -> str: + """Extract the topic string from an aiomqtt message.""" + message_topic = message.topic + topic = getattr(message_topic, "value", None) + if topic is None: + topic = str(message_topic) + return _validate_topic(topic) + + @staticmethod + def _message_payload(message: Any) -> bytes: + """Extract message payload without copying existing bytes.""" + payload = message.payload + if isinstance(payload, bytes): + return payload + return bytes(payload) + + def serialize_value( + self, + value: Any, + resource: RemoteResource, + ) -> bytes: + """Serialize a value using this connector's configured payload codec.""" + return normalize_mqtt_payload( + self._payload_serializer( + value, + resource, + ) + ) + + def deserialize_value( + self, + payload: bytes | bytearray, + resource: RemoteResource, + ) -> Any: + """Deserialize a payload using this connector's configured codec.""" + return self._payload_deserializer( + payload, + resource, + ) + + @property + def payload_codec(self) -> str: + """Return the configured built-in payload codec name.""" + return self._payload_codec + + @payload_codec.setter + def payload_codec(self, codec: str | MqttPayloadCodec) -> None: + """Configure built-in MQTT payload serialization.""" + payload_codec = get_mqtt_payload_codec(codec) + self._payload_codec = payload_codec.name + self._payload_serializer = payload_codec.serializer + self._payload_deserializer = payload_codec.deserializer + + @property + def payload_serializer(self) -> MqttPayloadSerializer: + """Return the active MQTT payload serializer.""" + return self._payload_serializer + + @payload_serializer.setter + def payload_serializer(self, serializer: MqttPayloadSerializer) -> None: + """Override the active MQTT payload serializer.""" + if not callable(serializer): + raise TypeError("MQTT payload_serializer must be callable") + self._payload_serializer = serializer + + @property + def payload_deserializer(self) -> MqttPayloadDeserializer: + """Return the active MQTT payload deserializer.""" + return self._payload_deserializer + + @payload_deserializer.setter + def payload_deserializer( + self, + deserializer: MqttPayloadDeserializer, + ) -> None: + """Override the active MQTT payload deserializer.""" + if not callable(deserializer): + raise TypeError("MQTT payload_deserializer must be callable") + self._payload_deserializer = deserializer + + def _resolve_subscribe_topic( + self, + resource: RemoteResource, + ) -> str: + """Resolve the topic used for reads and subscriptions.""" + path = resource.path + remote_resource_spec = self._mqtt_spec(resource) + if remote_resource_spec is None: + return _validate_topic(_join_topic(self.topic_prefix, path)) + return remote_resource_spec.resolve_subscribe_topic( + path=path, + default_topic_prefix=self.topic_prefix, + ) + + def _resolve_publish_topic( + self, + resource: RemoteResource, + ) -> str: + """Resolve the topic used for writes.""" + path = resource.path + remote_resource_spec = self._mqtt_spec(resource) + if remote_resource_spec is None: + return _validate_topic(_join_topic(self.topic_prefix, path)) + return remote_resource_spec.resolve_publish_topic( + path=path, + default_topic_prefix=self.topic_prefix, + ) + + def _mqtt_spec( + self, + resource: RemoteResource, + ) -> MqttRemoteResourceSpec | None: + """Return the MQTT spec or fail when another protocol is supplied.""" + if resource.spec is None: + return None + if not isinstance(resource.spec, MqttRemoteResourceSpec): + raise TypeError( + "resource.spec must be an MqttRemoteResourceSpec or None" + ) + return resource.spec + + def _resolve_qos(self, resource: RemoteResource) -> int: + """Resolve the QoS for a node operation.""" + remote_resource_spec = self._mqtt_spec(resource) + if ( + remote_resource_spec is not None + and remote_resource_spec.qos is not None + ): + return remote_resource_spec.qos + return self.qos + + def _resolve_retain(self, resource: RemoteResource) -> bool: + """Resolve the retain flag for a node publish.""" + remote_resource_spec = self._mqtt_spec(resource) + if ( + remote_resource_spec is not None + and remote_resource_spec.retain is not None + ): + return remote_resource_spec.retain + return self.retain + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary representation. + + Environment-variable references are preserved instead of their resolved + values so that a dump/reload stays env-driven. + """ + data: dict[str, Any] = { + "name": self.name, + "id": self.id, + } + data.update(self.address_to_dict()) + data.update(self.auth_to_dict()) + data.update( + { + "client_id": self.client_id, + "topic_prefix": self.topic_prefix, + "keepalive": self.keepalive, + "qos": self.qos, + "retain": self.retain, + "payload_codec": self.payload_codec, + } + ) + return data + + def __str__(self) -> str: + return ( + "MqttConnector(" + f"name={self.name!r}, " + f"id={self.id!r}, " + f"ip={self.ip!r}, " + f"port={self.port!r}, " + f"client_id={self.client_id!r}, " + f"topic_prefix={self.topic_prefix!r}, " + f"keepalive={self.keepalive!r}, " + f"qos={self.qos!r}, " + f"retain={self.retain!r}, " + f"payload_codec={self.payload_codec!r}" + ")" + ) + + def __repr__(self) -> str: + return self.__str__() diff --git a/machine_data_model/nodes/connectors/mqtt/mqtt_payload_codec.py b/machine_data_model/nodes/connectors/mqtt/mqtt_payload_codec.py new file mode 100644 index 0000000..d40585d --- /dev/null +++ b/machine_data_model/nodes/connectors/mqtt/mqtt_payload_codec.py @@ -0,0 +1,221 @@ +from collections.abc import Callable +from dataclasses import dataclass +import json +import re +from typing import Any, Final, cast + +import msgpack + +from machine_data_model.nodes.connectors.remote_resource import RemoteResource +from machine_data_model.nodes.variable_node import ( + BooleanVariableNode, + NumericalVariableNode, + StringVariableNode, +) + +MqttSerializedPayload = bytes | bytearray | str +MqttPayloadSerializer = Callable[[Any, RemoteResource], MqttSerializedPayload] +MqttPayloadDeserializer = Callable[[bytes | bytearray, RemoteResource], Any] + +_INT_LITERAL: Final = re.compile(r"^[+-]?\d+$") +_FLOAT_LITERAL: Final = re.compile( + r"^[+-]?(?:(?:\d+\.\d*)|(?:\.\d+)|(?:\d+))(?:[eE][+-]?\d+)?$" +) +_JSON_ENCODE: Final = json.JSONEncoder( + ensure_ascii=False, + separators=(",", ":"), +).encode +_JSON_LOADS: Final = json.loads +_MSGPACK_PACKB: Final = msgpack.packb +_MSGPACK_UNPACKB: Final = msgpack.unpackb + + +@dataclass(frozen=True) +class MqttPayloadCodec: + """Serialize and deserialize MQTT payloads.""" + + name: str + serializer: MqttPayloadSerializer + deserializer: MqttPayloadDeserializer + + def serialize( + self, + value: Any, + resource: RemoteResource, + ) -> MqttSerializedPayload: + """Serialize a value into an MQTT payload.""" + return self.serializer(value, resource) + + def deserialize( + self, + payload: bytes | bytearray, + resource: RemoteResource, + ) -> Any: + """Deserialize an MQTT payload into a Python value.""" + return self.deserializer(payload, resource) + + +def normalize_mqtt_payload(payload: MqttSerializedPayload) -> bytes: + """Normalize serializer output to bytes for MQTT publish calls.""" + if isinstance(payload, bytes): + return payload + if isinstance(payload, bytearray): + return bytes(payload) + if isinstance(payload, str): + return payload.encode("utf-8") + raise TypeError("MQTT payload serializers must return bytes or str") + + +def serialize_mqtt_value( + value: Any, + resource: RemoteResource, + codec: str | MqttPayloadCodec = "string", +) -> bytes: + """Serialize a value with one of the configured MQTT payload codecs.""" + payload_codec = get_mqtt_payload_codec(codec) + return normalize_mqtt_payload( + payload_codec.serialize( + value, + resource, + ) + ) + + +def deserialize_mqtt_value( + payload: bytes | bytearray, + resource: RemoteResource, + codec: str | MqttPayloadCodec = "string", +) -> Any: + """Deserialize a value with one of the configured MQTT payload codecs.""" + payload_codec = get_mqtt_payload_codec(codec) + return payload_codec.deserialize( + payload, + resource, + ) + + +def serialize_string_payload(value: Any, _resource: RemoteResource) -> bytes: + """Encode supported scalar values as UTF-8 MQTT payloads.""" + if isinstance(value, bool): + return b"true" if value else b"false" + if isinstance(value, str): + return value.encode("utf-8") + if isinstance(value, int | float): + return str(value).encode("utf-8") + raise TypeError( + "MQTT string payloads currently support only str, int, float, and bool" + ) + + +def deserialize_string_payload( + payload: bytes | bytearray, resource: RemoteResource +) -> Any: + """Decode supported scalar values from UTF-8 MQTT payloads.""" + value = payload.decode("utf-8") + node_type = resource.node_type + if node_type is None: + return _infer_scalar_value(value) + if issubclass(node_type, StringVariableNode): + return value + if issubclass(node_type, BooleanVariableNode): + return _decode_bool(value) + if issubclass(node_type, NumericalVariableNode): + return _decode_number(value) + return _infer_scalar_value(value) + + +def _infer_scalar_value(value: str) -> Any: + """Infer bool, int, float, or str from a decoded scalar string.""" + stripped_value = value.strip() + lower_value = stripped_value.lower() + if lower_value == "true": + return True + if lower_value == "false": + return False + if _INT_LITERAL.fullmatch(stripped_value): + return int(stripped_value) + if _FLOAT_LITERAL.fullmatch(stripped_value): + return float(stripped_value) + return value + + +def _decode_bool(value: str) -> bool: + """Decode a boolean string.""" + lower_value = value.strip().lower() + if lower_value == "true": + return True + if lower_value == "false": + return False + raise ValueError(f"Cannot decode MQTT payload {value!r} as a bool") + + +def _decode_number(value: str) -> int | float: + """Decode an integer or floating point string.""" + stripped_value = value.strip() + if _INT_LITERAL.fullmatch(stripped_value): + return int(stripped_value) + if _FLOAT_LITERAL.fullmatch(stripped_value): + return float(stripped_value) + raise ValueError(f"Cannot decode MQTT payload {value!r} as a number") + + +def serialize_json_payload(value: Any, _resource: RemoteResource) -> bytes: + """Encode a value as compact UTF-8 JSON.""" + return _JSON_ENCODE(value).encode("utf-8") + + +def deserialize_json_payload( + payload: bytes | bytearray, _resource: RemoteResource +) -> Any: + """Decode a JSON payload.""" + return _JSON_LOADS(payload) + + +def serialize_msgpack_payload(value: Any, _resource: RemoteResource) -> bytes: + """Encode a value as MessagePack.""" + return cast(bytes, _MSGPACK_PACKB(value, use_bin_type=True)) + + +def deserialize_msgpack_payload( + payload: bytes | bytearray, _resource: RemoteResource +) -> Any: + """Decode a MessagePack payload.""" + return _MSGPACK_UNPACKB(payload, raw=False) + + +STRING_MQTT_PAYLOAD_CODEC: Final = MqttPayloadCodec( + name="string", + serializer=serialize_string_payload, + deserializer=deserialize_string_payload, +) +JSON_MQTT_PAYLOAD_CODEC: Final = MqttPayloadCodec( + name="json", + serializer=serialize_json_payload, + deserializer=deserialize_json_payload, +) +MSGPACK_MQTT_PAYLOAD_CODEC: Final = MqttPayloadCodec( + name="msgpack", + serializer=serialize_msgpack_payload, + deserializer=deserialize_msgpack_payload, +) +_BUILTIN_PAYLOAD_CODECS: Final = { + "string": STRING_MQTT_PAYLOAD_CODEC, + "str": STRING_MQTT_PAYLOAD_CODEC, + "scalar": STRING_MQTT_PAYLOAD_CODEC, + "json": JSON_MQTT_PAYLOAD_CODEC, + "msgpack": MSGPACK_MQTT_PAYLOAD_CODEC, + "messagepack": MSGPACK_MQTT_PAYLOAD_CODEC, +} + + +def get_mqtt_payload_codec(codec: str | MqttPayloadCodec) -> MqttPayloadCodec: + """Return a configured MQTT payload codec.""" + if isinstance(codec, MqttPayloadCodec): + return codec + codec_name = codec.strip().lower() + if codec_name in _BUILTIN_PAYLOAD_CODECS: + return _BUILTIN_PAYLOAD_CODECS[codec_name] + raise ValueError( + "Unsupported MQTT payload codec. Expected 'string', 'json', or " + "'msgpack'." + ) diff --git a/machine_data_model/nodes/connectors/mqtt/mqtt_remote_resource_spec.py b/machine_data_model/nodes/connectors/mqtt/mqtt_remote_resource_spec.py new file mode 100644 index 0000000..caa9154 --- /dev/null +++ b/machine_data_model/nodes/connectors/mqtt/mqtt_remote_resource_spec.py @@ -0,0 +1,195 @@ +from typing import TYPE_CHECKING, Any + +from typing_extensions import override + +from machine_data_model.nodes.connectors.abstract_remote_resource_spec import ( + AbstractRemoteResourceSpec, +) + +if TYPE_CHECKING: + from machine_data_model.nodes.data_model_node import DataModelNode + + +def _normalize_topic(topic: str) -> str: + """Normalize a configured MQTT topic without changing inner segments.""" + return topic.strip("/") + + +def _join_topic(prefix: str | None, topic: str | None) -> str: + """Join a topic prefix and a relative topic.""" + parts = [] + if prefix is not None and prefix.strip("/"): + parts.append(prefix.strip("/")) + if topic is not None and topic.strip("/"): + parts.append(topic.strip("/")) + return "/".join(parts) + + +def _validate_topic(topic: str) -> str: + """Validate first-pass MQTT topic support.""" + topic = _normalize_topic(topic) + if not topic: + raise ValueError("MQTT topic cannot be empty") + if "+" in topic or "#" in topic: + raise ValueError("MQTT topic wildcards are not supported") + return topic + + +class MqttRemoteResourceSpec(AbstractRemoteResourceSpec): + """Represents node properties that are specific for MQTT.""" + + def __init__( + self, + remote_path: str | None = None, + topic: str | None = None, + topic_prefix: str | None = None, + publish_topic: str | None = None, + subscribe_topic: str | None = None, + qos: int | None = None, + retain: bool | None = None, + ) -> None: + """Constructor. + + Args: + remote_path (str | None): + Compatibility alias for an explicit MQTT topic. + topic (str | None): + Explicit read/write topic. + topic_prefix (str | None): + Inheritable prefix used for derived topics. + publish_topic (str | None): + Explicit write topic. + subscribe_topic (str | None): + Explicit read/subscription topic. + qos (int | None): + Per-node QoS override. + retain (bool | None): + Per-node retain override. + """ + super().__init__(remote_path=remote_path) + if qos is not None and qos not in (0, 1, 2): + raise ValueError("MQTT QoS must be 0, 1, or 2") + if retain is not None and not isinstance(retain, bool): + raise TypeError("MQTT retain must be a bool or None") + self.topic = topic + self.topic_prefix = topic_prefix + self.publish_topic = publish_topic + self.subscribe_topic = subscribe_topic + self.qos = qos + self.retain = retain + + @override + def get_remote_path( + self, + node: "DataModelNode | None" = None, + ) -> str | None: + """Return the explicit or locally derivable MQTT subscription topic.""" + if self.has_explicit_subscribe_topic(): + return self.resolve_subscribe_topic() + if self.topic_prefix and node is not None: + return self.resolve_subscribe_topic(path=node.qualified_name) + return None + + def has_explicit_subscribe_topic(self) -> bool: + """Return whether reads/subscriptions have an explicit topic.""" + return ( + self.subscribe_topic is not None + or self.topic is not None + or self.remote_path is not None + ) + + def resolve_subscribe_topic( + self, + path: str | None = None, + default_topic_prefix: str | None = None, + ) -> str: + """Resolve the MQTT topic used for reads and subscriptions.""" + explicit_topic = self._first_configured_topic( + self.subscribe_topic, + self.topic, + self.remote_path, + ) + if explicit_topic is not None: + return _validate_topic(explicit_topic) + return self._derive_topic(path, default_topic_prefix) + + def resolve_publish_topic( + self, + path: str | None = None, + default_topic_prefix: str | None = None, + ) -> str: + """Resolve the MQTT topic used for writes.""" + if self.publish_topic is not None: + return _validate_topic(self.publish_topic) + return self.resolve_subscribe_topic(path, default_topic_prefix) + + def _derive_topic( + self, + path: str | None, + default_topic_prefix: str | None, + ) -> str: + """Derive a topic from the node qualified name or provided path.""" + derived_path = path + topic = _join_topic( + self.topic_prefix + if self.topic_prefix is not None + else default_topic_prefix, + derived_path, + ) + return _validate_topic(topic) + + @staticmethod + def _first_configured_topic(*topics: str | None) -> str | None: + """Return the first configured topic, preserving empty strings.""" + for topic in topics: + if topic is not None: + return topic + return None + + @override + def inheritable_spec(self) -> "MqttRemoteResourceSpec": + """Returns the inheritable part of this object.""" + return MqttRemoteResourceSpec(topic_prefix=self.topic_prefix) + + @override + def clone_for_child(self) -> "MqttRemoteResourceSpec": + """Create an MQTT spec for a child node.""" + return MqttRemoteResourceSpec( + topic_prefix=self.topic_prefix, + ) + + @override + def inherit_spec(self, parent: AbstractRemoteResourceSpec) -> None: + """Inherits MQTT topic prefix from another spec.""" + if not isinstance(parent, MqttRemoteResourceSpec): + return + if self.topic_prefix is None: + self.topic_prefix = parent.topic_prefix + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary representation.""" + return { + "remote_path": self.remote_path, + "topic": self.topic, + "topic_prefix": self.topic_prefix, + "publish_topic": self.publish_topic, + "subscribe_topic": self.subscribe_topic, + "qos": self.qos, + "retain": self.retain, + } + + def __str__(self) -> str: + return ( + "MqttRemoteResourceSpec(" + f"remote_path={self.remote_path!r}, " + f"topic={self.topic!r}, " + f"topic_prefix={self.topic_prefix!r}, " + f"publish_topic={self.publish_topic!r}, " + f"subscribe_topic={self.subscribe_topic!r}, " + f"qos={self.qos!r}, " + f"retain={self.retain!r}" + ")" + ) + + def __repr__(self) -> str: + return self.__str__() diff --git a/machine_data_model/nodes/connectors/opcua/opcua_connector.py b/machine_data_model/nodes/connectors/opcua/opcua_connector.py index a1d44cd..6bc4936 100644 --- a/machine_data_model/nodes/connectors/opcua/opcua_connector.py +++ b/machine_data_model/nodes/connectors/opcua/opcua_connector.py @@ -42,14 +42,21 @@ from ..abstract_async_connector import AbstractAsyncConnector from ..abstract_connector import SubscriptionArguments -from ..abstract_remote_resource_spec import ( - AbstractRemoteResourceSpec, -) +from ..remote_resource import RemoteResource from .opcua_remote_resource_spec import OpcuaRemoteResourceSpec _logger = logging.getLogger(__name__) +def _require_asyncua_node(value: Any) -> asyncua.Node: + """Return an asyncua node or fail with a clear connector error.""" + if not isinstance(value, asyncua.Node): + raise TypeError( + f"Expected an asyncua.Node, but got {type(value).__name__}" + ) + return value + + @dataclass(frozen=True) class OpcuaSubscriptionArguments(SubscriptionArguments): """Data returned to the OPC UA subscription callback.""" @@ -391,36 +398,36 @@ async def _async_disconnect(self) -> bool: _logger.error(f"Couldn't disconnect '{self.name}' connector") _logger.error(e) return False + finally: + self.client = None _logger.debug(f"Disconnected '{self.name}' connector") return True - @override - async def _async_get_remote_node( - self, - path: str | None = None, - remote_resource_spec: AbstractRemoteResourceSpec | None = None, - ) -> asyncua.Node | None: - """Asynchronous function which returns the node from the OPC UA server. - - Args: - path (str): - Node's path. - remote_resource_spec (AbstractRemoteResourceSpec | None): - Protocol-specific properties for remote nodes. + def _opcua_spec( + self, resource: RemoteResource + ) -> OpcuaRemoteResourceSpec | None: + """Return the OPC UA spec or raise when another protocol is supplied.""" + if resource.spec is not None and not isinstance( + resource.spec, OpcuaRemoteResourceSpec + ): + raise TypeError( + "resource.spec must be an OpcuaRemoteResourceSpec or None" + ) + return resource.spec - Returns: - asyncua.Node | None: - The node from the OPC UA server if it exists, None otherwise. - """ - assert isinstance( - remote_resource_spec, OpcuaRemoteResourceSpec | None - ), "remote_resource_spec must be an OpcuaRemoteResourceSpec or None" + @override + async def _async_get_remote_resource( + self, resource: RemoteResource + ) -> asyncua.Node: + """Resolve the OPC UA node for a remote resource.""" + remote_resource_spec = self._opcua_spec(resource) + path = resource.path _logger.debug(f"Retrieving node '{path}' from OPC UA server") if self.client is None: - raise Exception( + raise RuntimeError( f"Couldn't retrieve remote node '{path}' using '{self.name}' " f"connector: the client is not connected" ) @@ -439,8 +446,9 @@ async def _async_get_remote_node( f"Retrieving node by node id " f"'{remote_resource_spec.node_id}'" ) - node = self.client.get_node(remote_resource_spec.node_id) - assert isinstance(node, asyncua.Node), "node must be a Node" + node = _require_asyncua_node( + self.client.get_node(remote_resource_spec.node_id) + ) remote_resource_spec.remote_node = node return node @@ -450,22 +458,23 @@ async def _async_get_remote_node( if remote_resource_spec.remote_path is not None else "" ) - if not path or path == "": + if not path: if remote_resource_spec is not None: - computed_path = remote_resource_spec.get_remote_path() + computed_path = remote_resource_spec.get_remote_path( + resource.node + ) if computed_path: path = computed_path - if not path or path == "": + if not path: raise ValueError( f"Couldn't retrieve node '{path}': empty path" ) - _logger.debug( - f"Retrieving node '{path}' by remote path " f"'{path}'" - ) + _logger.debug(f"Retrieving node '{path}' by remote path '{path}'") split_path = [p for p in path.split("/") if p] - node = await self.client.get_root_node().get_child(split_path) - assert isinstance(node, asyncua.Node), "node must be a Node" + node = _require_asyncua_node( + await self.client.get_root_node().get_child(split_path) + ) if remote_resource_spec is not None: remote_resource_spec.remote_node = node if not remote_resource_spec.has_node_id(): @@ -479,45 +488,22 @@ async def _async_get_remote_node( f" using the '{self.name}' " f"connector: the node doesn't exist", ) - @override - async def _async_read_node_value( - self, path: str, remote_resource_spec: AbstractRemoteResourceSpec | None - ) -> Any: - """Asynchronously reads the node's value from the server. - - Args: - path (str): - Node's path. - remote_resource_spec (AbstractRemoteResourceSpec | None): - Protocol-specific properties for remote nodes. - - Returns: - Any: - Value read from the server. - """ - assert isinstance( - remote_resource_spec, OpcuaRemoteResourceSpec | None - ), "remote_resource_spec must be an OpcuaRemoteResourceSpec or None" + async def _remote_node(self, resource: RemoteResource) -> asyncua.Node: + """Resolve a resource and fail clearly when the node does not exist.""" + return await self._async_get_remote_resource(resource) + @override + async def _async_read_node_value(self, resource: RemoteResource) -> Any: + """Asynchronously read the node's value from the server.""" + path = resource.path if self.client is None: - raise Exception( - f"Couldn't call remote method '{path}' using '{self.name}' " + raise RuntimeError( + f"Couldn't read remote node '{path}' using '{self.name}' " f"connector: the client is not connected" ) - node = None _logger.debug(f"Reading node '{path}'") - if remote_resource_spec is not None and ( - remote_resource_spec.has_remote_node() - ): - node = remote_resource_spec.remote_node - else: - node = await self._async_get_remote_node(path, remote_resource_spec) - if node is None: - raise ValueError( - f"Couldn't read value of '{path}' using the '{self.name}' " - f"connector: the node does not exist" - ) + node = await self._remote_node(resource) try: value = await node.get_value() except UaError as exp: @@ -531,49 +517,19 @@ async def _async_read_node_value( @override async def _async_write_node_value( self, - path: str, + resource: RemoteResource, value: Any, - remote_resource_spec: AbstractRemoteResourceSpec | None, ) -> bool: - """Function which asynchronously writes the value to the OPC UA server. - - Args: - path (str): - Node's path. - value (Any): - Value to write to the server. - remote_resource_spec (AbstractRemoteResourceSpec | None): - Protocol-specific properties for remote nodes. - - Returns: - bool: - True if the value was written successfully. - """ - assert isinstance( - remote_resource_spec, OpcuaRemoteResourceSpec | None - ), "remote_resource_spec must be an OpcuaRemoteResourceSpec or None" - + """Asynchronously write the value to the OPC UA server.""" + path = resource.path if self.client is None: - raise Exception( - f"Couldn't call remote method '{path}' using '{self.name}' " + raise RuntimeError( + f"Couldn't write remote node '{path}' using '{self.name}' " f"connector: the client is not connected" ) _logger.debug(f"Writing node '{path}' with value: {value}") - - node = None - if ( - remote_resource_spec is not None - and remote_resource_spec.has_remote_node() - ): - node = remote_resource_spec.remote_node - else: - node = await self._async_get_remote_node(path, remote_resource_spec) - if node is None: - raise ValueError( - f"Couldn't read value of '{path}' using the '{self.name}' " - f"connector: the node does not exist" - ) + node = await self._remote_node(resource) success = True try: @@ -586,7 +542,6 @@ async def _async_write_node_value( ) await node.write_value(value, current_value_type) - # Verify the write by reading back the value written_value = await node.get_value() if written_value != value: _logger.error( @@ -608,60 +563,32 @@ async def _async_write_node_value( @override async def _async_call_node_as_method( self, - path: str, + resource: RemoteResource, kwargs: dict[str, Any], - remote_resource_spec: AbstractRemoteResourceSpec | None, ) -> Any: - """Asynchronously calls a method on the OPC UA server. - - Args: - path (str): - Node/method path. - kwargs (dict[str, Any]): - Method arguments expressed as key/name - value pairs. - remote_resource_spec (AbstractRemoteResourceSpec | None): - Protocol-specific properties for remote nodes. - - Returns: - Any: - Method's returned value. - """ - assert isinstance( - remote_resource_spec, OpcuaRemoteResourceSpec | None - ), "remote_resource_spec must be an OpcuaRemoteResourceSpec or None" - + """Asynchronously call a method on the OPC UA server.""" + remote_resource_spec = self._opcua_spec(resource) + path = resource.path _logger.debug( f"Calling remote method '{path}', with the following parameters: " f"{kwargs}" ) if self.client is None: - raise Exception( + raise RuntimeError( f"Couldn't call remote method '{path}' using '{self.name}' " f"connector: the client is not connected" ) - node = None - if ( - remote_resource_spec is not None - and remote_resource_spec.has_remote_node() - ): - node = remote_resource_spec.remote_node - else: - node = await self._async_get_remote_node(path, remote_resource_spec) - if node is None: - raise ValueError( - f"Couldn't call remote method '{path}' using '{self.name}' " - f"connector: the node doesn't exist" - ) + node = await self._remote_node(resource) - # check if the node has inputs method_inputs = await get_input_arguments(node) inputs = [] if method_inputs is not None: - inputs = ( - await method_inputs.read_value() - ) # returns a list of Argument-Class - assert isinstance(inputs, list), "inputs must be a list" + inputs = await method_inputs.read_value() + if not isinstance(inputs, list): + raise TypeError( + f"Input arguments for method '{path}' must be a list" + ) params = [] kwargs_list = list(kwargs.items()) @@ -671,7 +598,6 @@ async def _async_call_node_as_method( identifier = dt.Identifier variant_type = VariantType(identifier) - # Get the value from kwargs by position or default to None if i < len(kwargs_list): param_name, value = kwargs_list[i] if value is not None: @@ -696,24 +622,19 @@ async def _async_call_node_as_method( try: _logger.debug(f"Calling '{path}' using the parent node. ") if ( - isinstance(remote_resource_spec, OpcuaRemoteResourceSpec) + remote_resource_spec is not None and remote_resource_spec.has_parent_node_id() ): _logger.debug( f"Using parent node id" f"{remote_resource_spec.parent_node_id}" ) - temp = OpcuaRemoteResourceSpec( - node_id=remote_resource_spec.parent_node_id, - ) - parent = await self._async_get_remote_node("", temp) - assert isinstance(parent, asyncua.Node), ( - "parent must be a Node,", - " {await node.get_parent().nodeid}", + parent = self.client.get_node( + remote_resource_spec.parent_node_id ) else: parent = await node.get_parent() - assert isinstance(parent, asyncua.Node), "parent must be a Node" + parent = _require_asyncua_node(parent) result = await parent.call_method(node.nodeid, *params) except UaError as exp: _logger.error( @@ -729,57 +650,27 @@ async def _async_call_node_as_method( @override async def _async_subscribe_to_node_changes( self, - path: str, - remote_resource_spec: AbstractRemoteResourceSpec | None, + resource: RemoteResource, callback: Callable[[Any, OpcuaSubscriptionArguments], None], ) -> int: - """Asynchronously subscribes to changes of the node at path . - - Args: - path (str): - Node path. - remote_resource_spec (AbstractRemoteResourceSpec | None): - Protocol-specific properties for remote nodes. - callback (Callable[[Any, SubscriptionArguments], None]): - Subscription's callback. The first parameter is the new value, - while the second parameter is additional data that is protocol - dependent. - - Returns: - int: - Handler code which can be used to unsubscribe from new events. - """ - assert isinstance( - remote_resource_spec, OpcuaRemoteResourceSpec | None - ), "remote_resource_spec must be an OpcuaRemoteResourceSpec or None" + """Asynchronously subscribe to changes of the remote node.""" + path = resource.path + self._opcua_spec(resource) _logger.debug(f"Subscribing to remote node '{path}'") if self.client is None: - raise Exception( + raise RuntimeError( f"Couldn't subscribe to '{path}': Client is not running inside " f"the '{self.name}' connector" ) handler = OpcUaDataChangeHandler(callback) subscription = await self.client.create_subscription(1, handler) - - node = None - if ( - remote_resource_spec is not None - and remote_resource_spec.has_remote_node() - ): - node = remote_resource_spec.remote_node - else: - node = await self._async_get_remote_node(path, remote_resource_spec) - - if node is None: - raise Exception( - f"Couldn't subscribe to unexisting node '{path}' using the " - f"'{self.name}' connector" - ) + node = await self._remote_node(resource) res = await subscription.subscribe_data_change(node) - assert isinstance(res, int), "subscription handler must be a int" + if not isinstance(res, int): + raise TypeError("subscription handler must be an int") _logger.debug( f"Subscribed to remote node '{path}'. " f"Subscription handler code: {res}" diff --git a/machine_data_model/nodes/connectors/opcua/opcua_remote_resource_spec.py b/machine_data_model/nodes/connectors/opcua/opcua_remote_resource_spec.py index 13e8c94..f452f1b 100644 --- a/machine_data_model/nodes/connectors/opcua/opcua_remote_resource_spec.py +++ b/machine_data_model/nodes/connectors/opcua/opcua_remote_resource_spec.py @@ -17,7 +17,6 @@ class OpcuaRemoteResourceSpec(AbstractRemoteResourceSpec): def __init__( self, - parent: "DataModelNode | None" = None, remote_path: str | None = None, node_id: str | None = None, namespace: str | None = None, @@ -26,8 +25,6 @@ def __init__( """Constructor. Args: - parent (DataModelNode, optional): - Node which owns these properties. remote_path (str, optional): Node's path on the remote OPC UA server. node_id (str, optional): @@ -37,7 +34,7 @@ def __init__( parent_node_id (str, optional): Parent node's id of node_id on the remote OPC UA server. """ - super().__init__(parent=parent, remote_path=remote_path) + super().__init__(remote_path=remote_path) self.node_id: str | None = node_id self.parent_node_id: str | None = parent_node_id self.namespace: str | None = namespace @@ -80,7 +77,10 @@ def has_remote_node(self) -> bool: return isinstance(self.remote_node, asyncua.Node) @override - def get_remote_path(self) -> str | None: + def get_remote_path( + self, + node: "DataModelNode | None" = None, + ) -> str | None: """Returns the 'remote_path' used to interact with the remote node. Returns: @@ -90,17 +90,14 @@ def get_remote_path(self) -> str | None: if self.remote_path is not None: return self.remote_path - if self.parent: + if node is not None: remote_path = "" - if self.parent.parent: - parent_remote_path = self.parent.parent.remote_path + if node.parent: + parent_remote_path = node.parent.remote_path remote_path = parent_remote_path if parent_remote_path else "" if self.namespace: - return ( - remote_path + "/" + self.namespace + ":" + self.parent.name - ) - else: - return remote_path + "/" + self.parent.name + return remote_path + "/" + self.namespace + ":" + node.name + return remote_path + "/" + node.name return None @override @@ -118,6 +115,24 @@ def inheritable_spec(self) -> "OpcuaRemoteResourceSpec": namespace=self.namespace, node_id=self.node_id ) + @override + def clone_for_child(self) -> "OpcuaRemoteResourceSpec": + """Create an OPC UA spec for a child node. + + The parent's node id becomes the child's parent node id, preserving the + inheritance behavior used by existing OPC UA data models. + + Returns: + OpcuaRemoteResourceSpec: + New OPC UA spec for the child node. + """ + inheritable_spec = self.inheritable_spec() + return OpcuaRemoteResourceSpec( + namespace=inheritable_spec.namespace, + remote_path=inheritable_spec.remote_path, + parent_node_id=inheritable_spec.node_id, + ) + @override def inherit_spec(self, parent: AbstractRemoteResourceSpec) -> None: """Inherits the properties from the given inheritable spec. diff --git a/machine_data_model/nodes/connectors/remote_resource.py b/machine_data_model/nodes/connectors/remote_resource.py new file mode 100644 index 0000000..fe99a9b --- /dev/null +++ b/machine_data_model/nodes/connectors/remote_resource.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +import weakref + +if TYPE_CHECKING: + from machine_data_model.nodes.data_model_node import DataModelNode + + from .abstract_remote_resource_spec import AbstractRemoteResourceSpec + + +class RemoteResource: + """Connector-facing reference to a remote data model resource. + + The data model owns the generic node tree and optional protocol-specific + specs. Connectors should receive a single resolved object instead of a loose + pair of ``path`` and ``remote_resource_spec`` arguments. + """ + + __slots__ = ("_node_ref", "_node_type", "_path", "_spec") + + def __init__( + self, + path: str, + spec: AbstractRemoteResourceSpec | None = None, + ) -> None: + """Create a resolved remote resource.""" + self._path = path + self._spec = spec + self._node_ref: weakref.ReferenceType[DataModelNode] | None = None + self._node_type: type[DataModelNode] | None = None + + @property + def path(self) -> str: + """Return the connector-facing remote path.""" + return self._path + + @property + def spec(self) -> AbstractRemoteResourceSpec | None: + """Return the protocol-specific remote resource spec.""" + return self._spec + + @property + def node(self) -> DataModelNode | None: + """Return the owning data model node if it is still alive.""" + if self._node_ref is None: + return None + return self._node_ref() + + @property + def node_type(self) -> type[DataModelNode] | None: + """Return the owning node's type, even after the node is collected. + + The type is captured when the resource is built from a node so that + decoding stays stable: a dropped weakref must not silently change how a + payload is interpreted. + """ + if self._node_type is not None: + return self._node_type + node = self.node + return type(node) if node is not None else None + + @classmethod + def from_node(cls, node: DataModelNode) -> RemoteResource: + """Build a remote resource reference from a data model node.""" + spec = node.remote_resource_spec + path = node.remote_path + if path is None: + if spec is None: + raise ValueError( + "Remote nodes must define either a remote path or a " + "remote resource spec" + ) + path = "" + resource = cls(path=path, spec=spec) + resource._node_ref = weakref.ref(node) + resource._node_type = type(node) + return resource + + def __repr__(self) -> str: + return ( + "RemoteResource(" f"path={self.path!r}, " f"spec={self.spec!r}" ")" + ) diff --git a/machine_data_model/nodes/data_model_node.py b/machine_data_model/nodes/data_model_node.py index 7e45cd9..29c9c0b 100644 --- a/machine_data_model/nodes/data_model_node.py +++ b/machine_data_model/nodes/data_model_node.py @@ -13,14 +13,14 @@ from .connectors.abstract_remote_resource_spec import ( AbstractRemoteResourceSpec, ) +from .connectors.remote_resource import RemoteResource if TYPE_CHECKING: - from nodes.connectors.abstract_connector import ( + from machine_data_model.data_model import DataModel + from machine_data_model.nodes.connectors.abstract_connector import ( AbstractConnector, ) - from machine_data_model.data_model import DataModel - class DataModelNode(ABC): """Abstract base class representing a node in the machine data model. @@ -100,8 +100,7 @@ def __init__( self._remote_resource_spec: AbstractRemoteResourceSpec | None = ( remote_resource_spec ) - if self._remote_resource_spec is not None: - self._remote_resource_spec.parent = self + self._remote_resource: RemoteResource | None = None @property def id(self) -> str: @@ -187,6 +186,7 @@ def remote_resource_spec( The remote resource spec to set. """ self._remote_resource_spec = value + self._remote_resource = None def set_connector_name(self, value: str | None) -> None: """Sets the connector name.""" @@ -245,6 +245,7 @@ def has_remote_path(self) -> bool: def set_remote_path(self, remote_path: str | None) -> None: """Sets the remote path used by the connector.""" self._remote_path = remote_path + self._remote_resource = None @property def remote_path(self) -> str | None: @@ -257,10 +258,27 @@ def remote_path(self) -> str | None: return self._remote_path if self._remote_resource_spec is not None: - return self._remote_resource_spec.get_remote_path() + return self._remote_resource_spec.get_remote_path(self) return None + @property + def remote_resource(self) -> RemoteResource: + """Return the resolved remote resource for this node. + + DataModel setup configures this once after inherited specs, remote path, + and connector inheritance have been resolved. The lazy fallback keeps + manually wired nodes usable in tests and direct integrations. + """ + if self._remote_resource is None: + return self.configure_remote_resource() + return self._remote_resource + + def configure_remote_resource(self) -> RemoteResource: + """Resolve and store the connector-facing remote resource.""" + self._remote_resource = RemoteResource.from_node(self) + return self._remote_resource + @property def data_model(self) -> "DataModel | None": """Gets the data model that contains this node. diff --git a/machine_data_model/nodes/method_node.py b/machine_data_model/nodes/method_node.py index dd362db..529c543 100644 --- a/machine_data_model/nodes/method_node.py +++ b/machine_data_model/nodes/method_node.py @@ -385,19 +385,10 @@ def __call__(self, *args: Any, **kwargs: Any) -> MethodExecutionResult: self._pre_call(**kwargs) if self.is_remote(): - assert isinstance( - self.connector, AbstractConnector - ), "connector must be an AbstractConnector" - assert ( - self.remote_path is not None - or self.remote_resource_spec is not None - ), "remote_path must be set for a remote node" - # Use empty string as fallback since remote_resource_spec can - # provide the path - path = self.remote_path if self.remote_path is not None else "" - ret_c = self.connector.call_node_as_method( - path, kwargs, self._remote_resource_spec - ) + connector = self.connector + if not isinstance(connector, AbstractConnector): + raise RuntimeError("Remote methods must have a valid connector") + ret_c = connector.call_node_as_method(self.remote_resource, kwargs) else: ret_c = self._callback(**kwargs) ret = self._build_return_dict(ret_c) diff --git a/machine_data_model/nodes/variable_node.py b/machine_data_model/nodes/variable_node.py index 64e4430..e72192f 100644 --- a/machine_data_model/nodes/variable_node.py +++ b/machine_data_model/nodes/variable_node.py @@ -24,7 +24,11 @@ trace_variable_write, ) -from .connectors.abstract_connector import SubscriptionArguments +from .connectors.abstract_connector import ( + AbstractConnector, + SubscriptionArguments, +) +from .connectors.remote_resource import RemoteResource from .data_model_node import DataModelNode from .measurement_unit.measure_builder import ( MeasureBuilder, @@ -378,6 +382,21 @@ def _read_value(self, force_remote_read: bool = False) -> Any: else: return self._read_internal_value() + def _require_remote_connector(self) -> AbstractConnector: + """Return the connector or fail with a runtime exception.""" + if self._connector is None: + raise RuntimeError("Remote nodes must have a valid connector") + return self._connector + + def _require_remote_resource(self) -> RemoteResource: + """Return the configured remote resource or fail clearly.""" + try: + return self.remote_resource + except ValueError as exp: + raise ValueError( + "Remote nodes must have a valid remote path or resource spec" + ) from exp + @abstractmethod def _read_internal_value(self) -> Any: """Read the value locally. @@ -400,21 +419,9 @@ def _read_remote_value(self, force_remote_read: bool = False) -> Any: Any: Variable's remote (or its cached) value. """ - assert ( - self._connector is not None - ), "Remote nodes must have a valid connector" - assert ( - self.remote_path is not None - or self._remote_resource_spec is not None - ), "Remote nodes must have a valid remote path" + connector = self._require_remote_connector() if force_remote_read: - # Use empty string as fallback since remote_resource_spec can - # provide the path - path = self.remote_path if self.remote_path is not None else "" - result = self._connector.read_node_value( - path, self._remote_resource_spec - ) - return result + return connector.read_node_value(self._require_remote_resource()) return self._read_internal_value() def _update_value(self, value: Any) -> Any: @@ -438,17 +445,10 @@ def _update_remote_value(self, value: Any) -> Any: New value when the update action is successful. Otherwise, it returns the previous value. """ - assert ( - self._connector is not None - ), "Remote nodes must have a valid connector" - assert ( - self.remote_path is not None - or self.remote_resource_spec is not None - ), "Remote nodes must have a valid remote path" + connector = self._require_remote_connector() prev_value = self._read_internal_value() - path = self.remote_path if self.remote_path is not None else "" - write_successful = self._connector.write_node_value( - path, value, self.remote_resource_spec + write_successful = connector.write_node_value( + self._require_remote_resource(), value ) if write_successful: return value @@ -519,20 +519,19 @@ def subscribe_to_remote_changes(self) -> None: When the new value is retrieved, calls the _remote_subscription_callback which updates the internal cached value. """ - if not self.connector or not ( + connector = self.connector + if connector is None or not ( self._remote_path or self._remote_resource_spec ): return None - # Use empty string as fallback since remote_resource_spec can - # provide the path - path = self.remote_path if self.remote_path is not None else "" - self.connector.subscribe_to_node_changes( - path, + resource = self.remote_resource + connector.subscribe_to_node_changes( + resource, self._remote_subscription_callback, - self._remote_resource_spec, ) - value = self._read_remote_value(force_remote_read=True) - self._update_internal_value(value) + value = connector.read_node_value(resource) + if value is not None: + self._update_internal_value(value) return None def _remote_subscription_callback( @@ -691,6 +690,8 @@ def _read_remote_value(self, force_remote_read: bool = False) -> float: The value of the numerical variable. """ result = super()._read_remote_value(force_remote_read) + if result is None: + return self._read_internal_value() assert isinstance(result, int | float) return result @@ -835,6 +836,8 @@ def _read_remote_value(self, force_remote_read: bool = False) -> str: The value of the string variable. """ result = super()._read_remote_value(force_remote_read) + if result is None: + return self._read_internal_value() assert isinstance(result, str) return result @@ -1004,6 +1007,8 @@ def _read_remote_value(self, force_remote_read: bool = False) -> bool: successful.Otherwise, returns the previous value. """ result = super()._read_remote_value(force_remote_read) + if result is None: + return self._read_internal_value() assert isinstance(result, bool) return result @@ -1269,12 +1274,8 @@ def _read_remote_value(self, force_remote_read: bool = False) -> Any: Any: The value of the object variable. """ - assert ( - self._connector is not None - ), "Remote nodes must have a valid connector" - assert ( - self.remote_path is not None - ), "Remote nodes must have a valid remote path" + self._require_remote_connector() + self._require_remote_resource() value = {} for property_name, property_node in self._properties.items(): if isinstance(property_node, VariableNode): @@ -1318,12 +1319,8 @@ def _update_remote_value(self, value: dict[str, Any]) -> dict[str, Any]: The updated value if the operation was successful. Otherwise, returns the previous value. """ - assert ( - self._connector is not None - ), "Remote nodes must have a valid connector" - assert ( - self.remote_path is not None - ), "Remote nodes must have a valid remote path" + self._require_remote_connector() + self._require_remote_resource() prev_value = self._read_internal_value() write_successful = True for property_name, property_value in value.items(): diff --git a/poetry.lock b/poetry.lock index b805d7b..d03d386 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. [[package]] name = "aiofiles" @@ -6,17 +6,34 @@ version = "25.1.0" description = "File support for asyncio." optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695"}, {file = "aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2"}, ] +[[package]] +name = "aiomqtt" +version = "2.5.1" +description = "The idiomatic asyncio MQTT client" +optional = false +python-versions = "<4.0,>=3.8" +groups = ["main"] +files = [ + {file = "aiomqtt-2.5.1-py3-none-any.whl", hash = "sha256:fd58c3593160e4d475d90ce911cdfc4239cd64de96b0ba22edf6c86bd7afa278"}, + {file = "aiomqtt-2.5.1.tar.gz", hash = "sha256:25a0a47d157e8f158d2da1110ea4786c0615518751e94f7b04976c977a8ff20d"}, +] + +[package.dependencies] +paho-mqtt = ">=2.1.0,<3.0.0" + [[package]] name = "aiosqlite" version = "0.22.1" description = "asyncio bridge to the standard sqlite3 module" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb"}, {file = "aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650"}, @@ -32,6 +49,7 @@ version = "1.0.0" description = "A light, configurable Sphinx theme" optional = false python-versions = ">=3.10" +groups = ["docs"] files = [ {file = "alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b"}, {file = "alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e"}, @@ -43,6 +61,7 @@ version = "4.1.2" description = "An abstract syntax tree for Python with inference support." optional = false python-versions = ">=3.10.0" +groups = ["docs"] files = [ {file = "astroid-4.1.2-py3-none-any.whl", hash = "sha256:21312e682c0866dc5a309ee57e4b88ea92751b9955a58b1c31371cbbeb088707"}, {file = "astroid-4.1.2.tar.gz", hash = "sha256:d6c4a52bfcda4bbeb7359dead642b0248b90f7d9a07e690230bd86fefd6d37f1"}, @@ -54,6 +73,7 @@ version = "1.1.6" description = "Pure Python OPC-UA client and server library" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "asyncua-1.1.6-py3-none-any.whl", hash = "sha256:6c94b9aa71f957abf5fc3eb76fc7359029097b44099c2b396e9e3e625671401f"}, {file = "asyncua-1.1.6.tar.gz", hash = "sha256:da0fff57477ce9bd50cd48ab3aca5fc4dbfa4917127664a3e04422aaab7a4b51"}, @@ -76,6 +96,7 @@ version = "2.18.0" description = "Internationalization utilities" optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35"}, {file = "babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d"}, @@ -90,6 +111,7 @@ version = "4.14.3" description = "Screen-scraping library" optional = false python-versions = ">=3.7.0" +groups = ["docs"] files = [ {file = "beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb"}, {file = "beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86"}, @@ -112,6 +134,7 @@ version = "7.1.1" description = "Extensible memoizing collections and decorators" optional = false python-versions = ">=3.10" +groups = ["dev"] files = [ {file = "cachetools-7.1.1-py3-none-any.whl", hash = "sha256:0335cd7a0952d2b22327441fb0628139e234c565559eeb91a8a4ac7551c5353d"}, {file = "cachetools-7.1.1.tar.gz", hash = "sha256:27bdf856d68fd3c71c26c01b5edc312124ed427524d1ddb31aa2b7746fe20d4b"}, @@ -123,6 +146,7 @@ version = "2026.4.22" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" +groups = ["dev", "docs"] files = [ {file = "certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a"}, {file = "certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580"}, @@ -134,6 +158,8 @@ version = "2.0.0" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.9" +groups = ["main"] +markers = "platform_python_implementation != \"PyPy\"" files = [ {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, @@ -230,6 +256,7 @@ version = "3.5.0" description = "Validate configuration and produce human readable error messages." optional = false python-versions = ">=3.10" +groups = ["dev"] files = [ {file = "cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0"}, {file = "cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132"}, @@ -241,6 +268,7 @@ version = "3.4.7" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" +groups = ["dev", "docs"] files = [ {file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"}, {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"}, @@ -379,10 +407,12 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev", "docs", "test"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {docs = "sys_platform == \"win32\"", test = "sys_platform == \"win32\""} [[package]] name = "coverage" @@ -390,6 +420,7 @@ version = "7.14.0" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.10" +groups = ["test"] files = [ {file = "coverage-7.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:84c32d90bf4537f0e7b4dec9aaa9a938fb8205136b9d2ecf4d7629d5262dc075"}, {file = "coverage-7.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7c843572c605ab51cfdb5c6b5f2586e2a8467c0d28eca4bdef4ec70c5fecbd82"}, @@ -508,6 +539,7 @@ version = "48.0.0" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = "!=3.9.0,!=3.9.1,>=3.9" +groups = ["main"] files = [ {file = "cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6"}, {file = "cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c"}, @@ -572,6 +604,7 @@ version = "0.4.0" description = "Distribution utilities" optional = false python-versions = "*" +groups = ["dev"] files = [ {file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"}, {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, @@ -583,6 +616,7 @@ version = "7.1.0" description = "A Python library for the Docker Engine API." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0"}, {file = "docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c"}, @@ -605,6 +639,7 @@ version = "0.21.2" description = "Docutils -- Python Documentation Utilities" optional = false python-versions = ">=3.9" +groups = ["docs"] files = [ {file = "docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2"}, {file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"}, @@ -616,6 +651,7 @@ version = "3.29.0" description = "A platform independent file lock." optional = false python-versions = ">=3.10" +groups = ["dev"] files = [ {file = "filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258"}, {file = "filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90"}, @@ -627,6 +663,7 @@ version = "2024.8.6" description = "A clean customisable Sphinx documentation theme." optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "furo-2024.8.6-py3-none-any.whl", hash = "sha256:6cd97c58b47813d3619e63e9081169880fbe331f0ca883c871ff1f3f11814f5c"}, {file = "furo-2024.8.6.tar.gz", hash = "sha256:b63e4cee8abfc3136d3bc03a3d45a76a850bada4d6374d24c1716b0e01394a01"}, @@ -644,6 +681,7 @@ version = "2.6.19" description = "File identification library for Python" optional = false python-versions = ">=3.10" +groups = ["dev"] files = [ {file = "identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a"}, {file = "identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842"}, @@ -658,6 +696,7 @@ version = "3.14" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.8" +groups = ["dev", "docs"] files = [ {file = "idna-3.14-py3-none-any.whl", hash = "sha256:e677eaf072e290f7b725f9acf0b3a2bd55f9fd6f7c70abe5f0e34823d0accf69"}, {file = "idna-3.14.tar.gz", hash = "sha256:466d810d7a2cc1022bea9b037c39728d51ae7dad40d480fc9b7d7ecf98ba8ee3"}, @@ -672,6 +711,7 @@ version = "1.5.0" description = "Getting image size from png/jpeg/jpeg2000/gif file" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +groups = ["docs"] files = [ {file = "imagesize-1.5.0-py2.py3-none-any.whl", hash = "sha256:32677681b3f434c2cb496f00e89c5a291247b35b1f527589909e008057da5899"}, {file = "imagesize-1.5.0.tar.gz", hash = "sha256:8bfc5363a7f2133a89f0098451e0bcb1cd71aba4dc02bbcecb39d99d40e1b94f"}, @@ -683,6 +723,7 @@ version = "2.3.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.10" +groups = ["test"] files = [ {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, @@ -694,6 +735,7 @@ version = "3.1.6" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" +groups = ["docs"] files = [ {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, @@ -711,6 +753,8 @@ version = "0.11.0" description = "Mypyc runtime library" optional = false python-versions = ">=3.9" +groups = ["dev"] +markers = "platform_python_implementation != \"PyPy\"" files = [ {file = "librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e94ebfcfa2d5e9926d6c3b9aa4617ffc42a845b4321fb84021b872358c82a0f"}, {file = "librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae627397a2f351560440d872d6f7c8dbb4072e57868e7b2fc5b8b430fe489d45"}, @@ -810,6 +854,7 @@ version = "1.22.1" description = "A code analyzer without caring the C/C++ header files. It works with Java, C/C++, JavaScript, Python, Ruby, Swift, Objective C. Metrics includes cyclomatic complexity number etc." optional = false python-versions = "*" +groups = ["dev"] files = [ {file = "lizard-1.22.1-py2.py3-none-any.whl", hash = "sha256:cc629472d4b031f7692bd7f7e78d2574029008148fba75e45f3592774c8e7420"}, {file = "lizard-1.22.1.tar.gz", hash = "sha256:29fe26f3746f8539227ba909a6143c749548690271e13a3f73204b22e2e62590"}, @@ -825,6 +870,7 @@ version = "0.7.1" description = "Create Python CLI apps with little to no effort at all!" optional = false python-versions = "*" +groups = ["dev"] files = [ {file = "mando-0.7.1-py2.py3-none-any.whl", hash = "sha256:26ef1d70928b6057ee3ca12583d73c63e05c49de8972d620c278a7b206581a8a"}, {file = "mando-0.7.1.tar.gz", hash = "sha256:18baa999b4b613faefb00eac4efadcf14f510b59b924b66e08289aa1de8c3500"}, @@ -842,6 +888,7 @@ version = "3.0.3" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.9" +groups = ["docs"] files = [ {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, @@ -934,12 +981,85 @@ files = [ {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, ] +[[package]] +name = "msgpack" +version = "1.1.2" +description = "MessagePack serializer" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "msgpack-1.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0051fffef5a37ca2cd16978ae4f0aef92f164df86823871b5162812bebecd8e2"}, + {file = "msgpack-1.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a605409040f2da88676e9c9e5853b3449ba8011973616189ea5ee55ddbc5bc87"}, + {file = "msgpack-1.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b696e83c9f1532b4af884045ba7f3aa741a63b2bc22617293a2c6a7c645f251"}, + {file = "msgpack-1.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:365c0bbe981a27d8932da71af63ef86acc59ed5c01ad929e09a0b88c6294e28a"}, + {file = "msgpack-1.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:41d1a5d875680166d3ac5c38573896453bbbea7092936d2e107214daf43b1d4f"}, + {file = "msgpack-1.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:354e81bcdebaab427c3df4281187edc765d5d76bfb3a7c125af9da7a27e8458f"}, + {file = "msgpack-1.1.2-cp310-cp310-win32.whl", hash = "sha256:e64c8d2f5e5d5fda7b842f55dec6133260ea8f53c4257d64494c534f306bf7a9"}, + {file = "msgpack-1.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:db6192777d943bdaaafb6ba66d44bf65aa0e9c5616fa1d2da9bb08828c6b39aa"}, + {file = "msgpack-1.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e86a607e558d22985d856948c12a3fa7b42efad264dca8a3ebbcfa2735d786c"}, + {file = "msgpack-1.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:283ae72fc89da59aa004ba147e8fc2f766647b1251500182fac0350d8af299c0"}, + {file = "msgpack-1.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61c8aa3bd513d87c72ed0b37b53dd5c5a0f58f2ff9f26e1555d3bd7948fb7296"}, + {file = "msgpack-1.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:454e29e186285d2ebe65be34629fa0e8605202c60fbc7c4c650ccd41870896ef"}, + {file = "msgpack-1.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7bc8813f88417599564fafa59fd6f95be417179f76b40325b500b3c98409757c"}, + {file = "msgpack-1.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bafca952dc13907bdfdedfc6a5f579bf4f292bdd506fadb38389afa3ac5b208e"}, + {file = "msgpack-1.1.2-cp311-cp311-win32.whl", hash = "sha256:602b6740e95ffc55bfb078172d279de3773d7b7db1f703b2f1323566b878b90e"}, + {file = "msgpack-1.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:d198d275222dc54244bf3327eb8cbe00307d220241d9cec4d306d49a44e85f68"}, + {file = "msgpack-1.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:86f8136dfa5c116365a8a651a7d7484b65b13339731dd6faebb9a0242151c406"}, + {file = "msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa"}, + {file = "msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb"}, + {file = "msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f"}, + {file = "msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42"}, + {file = "msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9"}, + {file = "msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620"}, + {file = "msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029"}, + {file = "msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b"}, + {file = "msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69"}, + {file = "msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf"}, + {file = "msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7"}, + {file = "msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999"}, + {file = "msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e"}, + {file = "msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162"}, + {file = "msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794"}, + {file = "msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c"}, + {file = "msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9"}, + {file = "msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84"}, + {file = "msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00"}, + {file = "msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939"}, + {file = "msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e"}, + {file = "msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931"}, + {file = "msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014"}, + {file = "msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2"}, + {file = "msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717"}, + {file = "msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b"}, + {file = "msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af"}, + {file = "msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a"}, + {file = "msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b"}, + {file = "msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245"}, + {file = "msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90"}, + {file = "msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20"}, + {file = "msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27"}, + {file = "msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b"}, + {file = "msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff"}, + {file = "msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46"}, + {file = "msgpack-1.1.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ea5405c46e690122a76531ab97a079e184c0daf491e588592d6a23d3e32af99e"}, + {file = "msgpack-1.1.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9fba231af7a933400238cb357ecccf8ab5d51535ea95d94fc35b7806218ff844"}, + {file = "msgpack-1.1.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a8f6e7d30253714751aa0b0c84ae28948e852ee7fb0524082e6716769124bc23"}, + {file = "msgpack-1.1.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94fd7dc7d8cb0a54432f296f2246bc39474e017204ca6f4ff345941d4ed285a7"}, + {file = "msgpack-1.1.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:350ad5353a467d9e3b126d8d1b90fe05ad081e2e1cef5753f8c345217c37e7b8"}, + {file = "msgpack-1.1.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6bde749afe671dc44893f8d08e83bf475a1a14570d67c4bb5cec5573463c8833"}, + {file = "msgpack-1.1.2-cp39-cp39-win32.whl", hash = "sha256:ad09b984828d6b7bb52d1d1d0c9be68ad781fa004ca39216c8a1e63c0f34ba3c"}, + {file = "msgpack-1.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:67016ae8c8965124fdede9d3769528ad8284f14d635337ffa6a713a580f6c030"}, + {file = "msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e"}, +] + [[package]] name = "mypy" version = "1.20.2" description = "Optional static typing for Python" optional = false python-versions = ">=3.10" +groups = ["dev"] files = [ {file = "mypy-1.20.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cf5a4db6dca263010e2c7bff081c89383c72d187ba2cf4c44759aac970e2f0c4"}, {file = "mypy-1.20.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7b0e817b518bff7facd7f85ea05b643ad8bdcce684cf29784987b0a7c8e1f997"}, @@ -1010,6 +1130,7 @@ version = "1.1.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, @@ -1021,6 +1142,7 @@ version = "1.10.0" description = "Node.js virtual environment builder" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] files = [ {file = "nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827"}, {file = "nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb"}, @@ -1032,17 +1154,34 @@ version = "26.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" +groups = ["dev", "docs", "test"] files = [ {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, ] +[[package]] +name = "paho-mqtt" +version = "2.1.0" +description = "MQTT version 5.0/3.1.1 client class" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee"}, + {file = "paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834"}, +] + +[package.extras] +proxy = ["pysocks"] + [[package]] name = "pathspec" version = "1.1.1" description = "Utility library for gitignore style pattern matching of file paths." optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189"}, {file = "pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a"}, @@ -1059,6 +1198,7 @@ version = "4.9.6" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.10" +groups = ["dev"] files = [ {file = "platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917"}, {file = "platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a"}, @@ -1070,6 +1210,7 @@ version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.9" +groups = ["dev", "test"] files = [ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, @@ -1085,6 +1226,7 @@ version = "4.6.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false python-versions = ">=3.10" +groups = ["dev"] files = [ {file = "pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b"}, {file = "pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9"}, @@ -1103,6 +1245,8 @@ version = "3.0" description = "C parser in Python" optional = false python-versions = ">=3.10" +groups = ["main"] +markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" files = [ {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, @@ -1114,6 +1258,7 @@ version = "2.20.0" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.9" +groups = ["dev", "docs", "test"] files = [ {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, @@ -1128,6 +1273,7 @@ version = "26.2.0" description = "Python wrapper module around the OpenSSL library" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70"}, {file = "pyopenssl-26.2.0.tar.gz", hash = "sha256:8c6fcecd1183a7fc897548dfe388b0cdb7f37e018200d8409cf33959dbe35387"}, @@ -1147,6 +1293,7 @@ version = "1.10.0" description = "API to interact with the python pyproject.toml based projects" optional = false python-versions = ">=3.10" +groups = ["dev"] files = [ {file = "pyproject_api-1.10.0-py3-none-any.whl", hash = "sha256:8757c41a79c0f4ab71b99abed52b97ecf66bd20b04fa59da43b5840bac105a09"}, {file = "pyproject_api-1.10.0.tar.gz", hash = "sha256:40c6f2d82eebdc4afee61c773ed208c04c19db4c4a60d97f8d7be3ebc0bbb330"}, @@ -1165,6 +1312,7 @@ version = "8.4.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.9" +groups = ["test"] files = [ {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, @@ -1186,6 +1334,7 @@ version = "6.3.0" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.9" +groups = ["test"] files = [ {file = "pytest_cov-6.3.0-py3-none-any.whl", hash = "sha256:440db28156d2468cafc0415b4f8e50856a0d11faefa38f30906048fe490f1749"}, {file = "pytest_cov-6.3.0.tar.gz", hash = "sha256:35c580e7800f87ce892e687461166e1ac2bcb8fb9e13aea79032518d6e503ff2"}, @@ -1205,6 +1354,7 @@ version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, @@ -1219,6 +1369,7 @@ version = "1.3.0" description = "Python interpreter discovery" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "python_discovery-1.3.0-py3-none-any.whl", hash = "sha256:441d9ced3dfce36e113beb35ca302c71c7ef06f3c0f9c227a0b9bb3bd49b9e9f"}, {file = "python_discovery-1.3.0.tar.gz", hash = "sha256:d098f1e86be5d45fe4d14bf1029294aabbd332f4321179dec85e76cddce834b0"}, @@ -1238,6 +1389,7 @@ version = "2026.2" description = "World timezone definitions, modern and historical" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126"}, {file = "pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a"}, @@ -1249,6 +1401,8 @@ version = "311" description = "Python for Window Extensions" optional = false python-versions = "*" +groups = ["dev"] +markers = "sys_platform == \"win32\"" files = [ {file = "pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3"}, {file = "pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b"}, @@ -1278,6 +1432,7 @@ version = "6.0.3" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" +groups = ["main", "dev", "docs"] files = [ {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, @@ -1360,6 +1515,7 @@ version = "6.0.1" description = "Code Metrics in Python" optional = false python-versions = "*" +groups = ["dev"] files = [ {file = "radon-6.0.1-py2.py3-none-any.whl", hash = "sha256:632cc032364a6f8bb1010a2f6a12d0f14bc7e5ede76585ef29dc0cecf4cd8859"}, {file = "radon-6.0.1.tar.gz", hash = "sha256:d1ac0053943a893878940fedc8b19ace70386fc9c9bf0a09229a44125ebf45b5"}, @@ -1378,6 +1534,7 @@ version = "2.33.1" description = "Python HTTP for Humans." optional = false python-versions = ">=3.10" +groups = ["dev", "docs"] files = [ {file = "requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a"}, {file = "requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517"}, @@ -1399,6 +1556,7 @@ version = "4.1.0" description = "Manipulate well-formed Roman numerals" optional = false python-versions = ">=3.10" +groups = ["docs"] files = [ {file = "roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7"}, {file = "roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2"}, @@ -1410,6 +1568,7 @@ version = "4.1.0" description = "This package is deprecated, switch to roman-numerals." optional = false python-versions = ">=3.10" +groups = ["docs"] files = [ {file = "roman_numerals_py-4.1.0-py3-none-any.whl", hash = "sha256:553114c1167141c1283a51743759723ecd05604a1b6b507225e91dc1a6df0780"}, {file = "roman_numerals_py-4.1.0.tar.gz", hash = "sha256:f5d7b2b4ca52dd855ef7ab8eb3590f428c0b1ea480736ce32b01fef2a5f8daf9"}, @@ -1424,6 +1583,7 @@ version = "0.8.6" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "ruff-0.8.6-py3-none-linux_armv6l.whl", hash = "sha256:defed167955d42c68b407e8f2e6f56ba52520e790aba4ca707a9c88619e580e3"}, {file = "ruff-0.8.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:54799ca3d67ae5e0b7a7ac234baa657a9c1784b48ec954a094da7c206e0365b1"}, @@ -1451,6 +1611,7 @@ version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main", "dev"] files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, @@ -1462,6 +1623,7 @@ version = "3.0.1" description = "This package provides 32 stemmers for 30 languages generated from Snowball algorithms." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*" +groups = ["docs"] files = [ {file = "snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064"}, {file = "snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895"}, @@ -1473,6 +1635,7 @@ version = "2.4.0" description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, @@ -1484,6 +1647,7 @@ version = "2.8.3" description = "A modern CSS selector implementation for Beautiful Soup." optional = false python-versions = ">=3.9" +groups = ["docs"] files = [ {file = "soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95"}, {file = "soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349"}, @@ -1495,6 +1659,7 @@ version = "8.2.3" description = "Python documentation generator" optional = false python-versions = ">=3.11" +groups = ["docs"] files = [ {file = "sphinx-8.2.3-py3-none-any.whl", hash = "sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3"}, {file = "sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348"}, @@ -1530,6 +1695,7 @@ version = "3.8.0" description = "Sphinx API documentation generator" optional = false python-versions = ">=3.10" +groups = ["docs"] files = [ {file = "sphinx_autoapi-3.8.0-py3-none-any.whl", hash = "sha256:245aefdeab85609ae4aa3576b0d99f69676aa6333dda438761bd125755b3c42d"}, {file = "sphinx_autoapi-3.8.0.tar.gz", hash = "sha256:9f8ac7d43baf28a0831ac0e392fab6a095b875af07e52d135a5f716cc3cf1142"}, @@ -1547,6 +1713,7 @@ version = "1.0.0b2" description = "A modern skeleton for Sphinx themes." optional = false python-versions = ">=3.7" +groups = ["docs"] files = [ {file = "sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b"}, {file = "sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9"}, @@ -1564,6 +1731,7 @@ version = "3.1.0" description = "Read the Docs theme for Sphinx" optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "sphinx_rtd_theme-3.1.0-py2.py3-none-any.whl", hash = "sha256:1785824ae8e6632060490f67cf3a72d404a85d2d9fc26bce3619944de5682b89"}, {file = "sphinx_rtd_theme-3.1.0.tar.gz", hash = "sha256:b44276f2c276e909239a4f6c955aa667aaafeb78597923b1c60babc76db78e4c"}, @@ -1583,6 +1751,7 @@ version = "2.0.0" description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" optional = false python-versions = ">=3.9" +groups = ["docs"] files = [ {file = "sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5"}, {file = "sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1"}, @@ -1599,6 +1768,7 @@ version = "2.0.0" description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp documents" optional = false python-versions = ">=3.9" +groups = ["docs"] files = [ {file = "sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2"}, {file = "sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad"}, @@ -1615,6 +1785,7 @@ version = "2.1.0" description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" optional = false python-versions = ">=3.9" +groups = ["docs"] files = [ {file = "sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8"}, {file = "sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9"}, @@ -1631,6 +1802,7 @@ version = "4.1" description = "Extension to include jQuery on newer Sphinx releases" optional = false python-versions = ">=2.7" +groups = ["docs"] files = [ {file = "sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a"}, {file = "sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae"}, @@ -1645,6 +1817,7 @@ version = "1.0.1" description = "A sphinx extension which renders display math in HTML via JavaScript" optional = false python-versions = ">=3.5" +groups = ["docs"] files = [ {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, @@ -1659,6 +1832,7 @@ version = "2.0.0" description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp documents" optional = false python-versions = ">=3.9" +groups = ["docs"] files = [ {file = "sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb"}, {file = "sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab"}, @@ -1675,6 +1849,7 @@ version = "2.0.0" description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)" optional = false python-versions = ">=3.9" +groups = ["docs"] files = [ {file = "sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331"}, {file = "sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d"}, @@ -1691,6 +1866,7 @@ version = "1.2.0" description = "A lil' TOML writer" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90"}, {file = "tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021"}, @@ -1702,6 +1878,7 @@ version = "4.53.1" description = "tox is a generic virtualenv management and test command line tool" optional = false python-versions = ">=3.10" +groups = ["dev"] files = [ {file = "tox-4.53.1-py3-none-any.whl", hash = "sha256:4a9948607e976a337c22d64a1b4fafd486125e82f00ab6ce32fa6cacc23f48b1"}, {file = "tox-4.53.1.tar.gz", hash = "sha256:7be9805ed4a34242510c7acc9a7e3a01a35942e08f31f8bd69067c3a37130afc"}, @@ -1728,6 +1905,7 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" +groups = ["main", "dev", "docs"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, @@ -1739,6 +1917,7 @@ version = "0.1.154" description = "A better way to hold unit variables and easily convert to the destination unit" optional = false python-versions = "<4.0,>=3.8" +groups = ["main"] files = [ {file = "unitsnet-py-0.1.154.tar.gz", hash = "sha256:2407e1017512eea1bbd51d60db532d3afd7cfcf771919b5854bdffbd4110c30d"}, ] @@ -1749,6 +1928,7 @@ version = "2.7.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.10" +groups = ["dev", "docs"] files = [ {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, @@ -1766,6 +1946,7 @@ version = "21.3.1" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "virtualenv-21.3.1-py3-none-any.whl", hash = "sha256:d1a71cf58f2f9228fff23a1f6ec15d39785c6b32e03658d104974247145edd35"}, {file = "virtualenv-21.3.1.tar.gz", hash = "sha256:c2305bc1fddeec40699b8370d13f8d431b0701f00ce895061ce493aeded4426b"}, @@ -1783,11 +1964,13 @@ version = "0.3.2" description = "Asyncio wait_for that can handle simultaneous cancellation and future completion." optional = false python-versions = "*" +groups = ["main"] +markers = "python_version < \"3.12\"" files = [ {file = "wait_for2-0.3.2.tar.gz", hash = "sha256:93863026dc35f3471104ecf7de1f4a0b31f4c8b12a2241c0d6ee26dcc0c2092a"}, ] [metadata] -lock-version = "2.0" +lock-version = "2.1" python-versions = "^3.11" -content-hash = "6babac2ea090af1c937d1a258aec147e6f9d9f213615c8aa12a021f8a4770421" +content-hash = "c97366dd0b844f1d8c960489ca768bd4c873a9cd6c9f624ffe8e3a3afeec34c4" diff --git a/pyproject.toml b/pyproject.toml index a959ee5..885aaac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,9 @@ readme = "README.md" python = "^3.11" pyyaml = "^6.0.2" unitsnet-py = "^0.1.154" -asyncua = "1.1.6" +asyncua = "1.1.6" +aiomqtt = "^2.5.1" +msgpack = "^1.1.2" [tool.poetry.group.dev.dependencies] diff --git a/requirements-dev.txt b/requirements-dev.txt index d01fb49..a9a0b0b 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,61 +1,50 @@ -alabaster==1.0.0 ; python_version >= "3.11" and python_version < "4.0" -astroid==3.3.11 ; python_version >= "3.11" and python_version < "4.0" +aiofiles==25.1.0 ; python_version >= "3.11" and python_version < "4.0" +aiomqtt==2.5.1 ; python_version >= "3.11" and python_version < "4.0" +aiosqlite==0.22.1 ; python_version >= "3.11" and python_version < "4.0" asyncua==1.1.6 ; python_version >= "3.11" and python_version < "4.0" -docker==7.1.0 ; python_version >= "3.11" and python_version < "4.0" -babel==2.17.0 ; python_version >= "3.11" and python_version < "4.0" -beautifulsoup4==4.13.5 ; python_version >= "3.11" and python_version < "4.0" -cachetools==6.2.0 ; python_version >= "3.11" and python_version < "4.0" -certifi==2025.8.3 ; python_version >= "3.11" and python_version < "4.0" -cfgv==3.4.0 ; python_version >= "3.11" and python_version < "4.0" -chardet==5.2.0 ; python_version >= "3.11" and python_version < "4.0" -charset-normalizer==3.4.3 ; python_version >= "3.11" and python_version < "4.0" +cachetools==7.1.1 ; python_version >= "3.11" and python_version < "4.0" +certifi==2026.4.22 ; python_version >= "3.11" and python_version < "4.0" +cffi==2.0.0 ; python_version >= "3.11" and python_version < "4.0" and platform_python_implementation != "PyPy" +cfgv==3.5.0 ; python_version >= "3.11" and python_version < "4.0" +charset-normalizer==3.4.7 ; python_version >= "3.11" and python_version < "4.0" colorama==0.4.6 ; python_version >= "3.11" and python_version < "4.0" -coverage[toml]==7.10.6 ; python_version >= "3.11" and python_version < "4.0" +cryptography==48.0.0 ; python_version >= "3.11" and python_version < "4.0" distlib==0.4.0 ; python_version >= "3.11" and python_version < "4.0" -docutils==0.21.2 ; python_version >= "3.11" and python_version < "4.0" -filelock==3.19.1 ; python_version >= "3.11" and python_version < "4.0" -furo==2024.8.6 ; python_version >= "3.11" and python_version < "4.0" -identify==2.6.14 ; python_version >= "3.11" and python_version < "4.0" -idna==3.10 ; python_version >= "3.11" and python_version < "4.0" -imagesize==1.4.1 ; python_version >= "3.11" and python_version < "4.0" -iniconfig==2.1.0 ; python_version >= "3.11" and python_version < "4.0" -jinja2==3.1.6 ; python_version >= "3.11" and python_version < "4.0" -lizard==1.17.31 ; python_version >= "3.11" and python_version < "4.0" +docker==7.1.0 ; python_version >= "3.11" and python_version < "4.0" +filelock==3.29.0 ; python_version >= "3.11" and python_version < "4.0" +identify==2.6.19 ; python_version >= "3.11" and python_version < "4.0" +idna==3.14 ; python_version >= "3.11" and python_version < "4.0" +librt==0.11.0 ; python_version >= "3.11" and python_version < "4.0" and platform_python_implementation != "PyPy" +lizard==1.22.1 ; python_version >= "3.11" and python_version < "4.0" mando==0.7.1 ; python_version >= "3.11" and python_version < "4.0" -markupsafe==3.0.2 ; python_version >= "3.11" and python_version < "4.0" +msgpack==1.1.2 ; python_version >= "3.11" and python_version < "4.0" mypy-extensions==1.1.0 ; python_version >= "3.11" and python_version < "4.0" -mypy==1.18.1 ; python_version >= "3.11" and python_version < "4.0" -nodeenv==1.9.1 ; python_version >= "3.11" and python_version < "4.0" -packaging==25.0 ; python_version >= "3.11" and python_version < "4.0" -pathspec==0.12.1 ; python_version >= "3.11" and python_version < "4.0" -platformdirs==4.4.0 ; python_version >= "3.11" and python_version < "4.0" +mypy==1.20.2 ; python_version >= "3.11" and python_version < "4.0" +nodeenv==1.10.0 ; python_version >= "3.11" and python_version < "4.0" +packaging==26.2 ; python_version >= "3.11" and python_version < "4.0" +paho-mqtt==2.1.0 ; python_version >= "3.11" and python_version < "4.0" +pathspec==1.1.1 ; python_version >= "3.11" and python_version < "4.0" +platformdirs==4.9.6 ; python_version >= "3.11" and python_version < "4.0" pluggy==1.6.0 ; python_version >= "3.11" and python_version < "4.0" -pre-commit==4.3.0 ; python_version >= "3.11" and python_version < "4.0" -pygments==2.19.2 ; python_version >= "3.11" and python_version < "4.0" -pyproject-api==1.9.1 ; python_version >= "3.11" and python_version < "4.0" -pytest-cov==6.3.0 ; python_version >= "3.11" and python_version < "4.0" -pytest==8.4.2 ; python_version >= "3.11" and python_version < "4.0" -pyyaml==6.0.2 ; python_version >= "3.11" and python_version < "4.0" +pre-commit==4.6.0 ; python_version >= "3.11" and python_version < "4.0" +pycparser==3.0 ; python_version >= "3.11" and python_version < "4.0" and platform_python_implementation != "PyPy" and implementation_name != "PyPy" +pygments==2.20.0 ; python_version >= "3.11" and python_version < "4.0" +pyopenssl==26.2.0 ; python_version >= "3.11" and python_version < "4.0" +pyproject-api==1.10.0 ; python_version >= "3.11" and python_version < "4.0" +python-dateutil==2.9.0.post0 ; python_version >= "3.11" and python_version < "4.0" +python-discovery==1.3.0 ; python_version >= "3.11" and python_version < "4.0" +pytz==2026.2 ; python_version >= "3.11" and python_version < "4.0" +pywin32==311 ; python_version >= "3.11" and python_version < "4.0" and sys_platform == "win32" +pyyaml==6.0.3 ; python_version >= "3.11" and python_version < "4.0" radon==6.0.1 ; python_version >= "3.11" and python_version < "4.0" -requests==2.32.5 ; python_version >= "3.11" and python_version < "4.0" -roman-numerals-py==3.1.0 ; python_version >= "3.11" and python_version < "4.0" +requests==2.33.1 ; python_version >= "3.11" and python_version < "4.0" ruff==0.8.6 ; python_version >= "3.11" and python_version < "4.0" six==1.17.0 ; python_version >= "3.11" and python_version < "4.0" -snowballstemmer==3.0.1 ; python_version >= "3.11" and python_version < "4.0" -soupsieve==2.8 ; python_version >= "3.11" and python_version < "4.0" -sphinx-autoapi==3.6.0 ; python_version >= "3.11" and python_version < "4.0" -sphinx-basic-ng==1.0.0b2 ; python_version >= "3.11" and python_version < "4.0" -sphinx-rtd-theme==3.0.2 ; python_version >= "3.11" and python_version < "4.0" -sphinx==8.2.3 ; python_version >= "3.11" and python_version < "4.0" -sphinxcontrib-applehelp==2.0.0 ; python_version >= "3.11" and python_version < "4.0" -sphinxcontrib-devhelp==2.0.0 ; python_version >= "3.11" and python_version < "4.0" -sphinxcontrib-htmlhelp==2.1.0 ; python_version >= "3.11" and python_version < "4.0" -sphinxcontrib-jquery==4.1 ; python_version >= "3.11" and python_version < "4.0" -sphinxcontrib-jsmath==1.0.1 ; python_version >= "3.11" and python_version < "4.0" -sphinxcontrib-qthelp==2.0.0 ; python_version >= "3.11" and python_version < "4.0" -sphinxcontrib-serializinghtml==2.0.0 ; python_version >= "3.11" and python_version < "4.0" -tox==4.30.2 ; python_version >= "3.11" and python_version < "4.0" +sortedcontainers==2.4.0 ; python_version >= "3.11" and python_version < "4.0" +tomli-w==1.2.0 ; python_version >= "3.11" and python_version < "4.0" +tox==4.53.1 ; python_version >= "3.11" and python_version < "4.0" typing-extensions==4.15.0 ; python_version >= "3.11" and python_version < "4.0" unitsnet-py==0.1.154 ; python_version >= "3.11" and python_version < "4.0" -urllib3==2.5.0 ; python_version >= "3.11" and python_version < "4.0" -virtualenv==20.34.0 ; python_version >= "3.11" and python_version < "4.0" +urllib3==2.7.0 ; python_version >= "3.11" and python_version < "4.0" +virtualenv==21.3.1 ; python_version >= "3.11" and python_version < "4.0" +wait-for2==0.3.2 ; python_version >= "3.11" and python_version < "3.12" diff --git a/requirements.txt b/requirements.txt index f4ed1ff..d0ce550 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,18 @@ -pyyaml==6.0.2 ; python_version >= "3.11" and python_version < "4.0" -unitsnet-py==0.1.154 ; python_version >= "3.11" and python_version < "4.0" +aiofiles==25.1.0 ; python_version >= "3.11" and python_version < "4.0" +aiomqtt==2.5.1 ; python_version >= "3.11" and python_version < "4.0" +aiosqlite==0.22.1 ; python_version >= "3.11" and python_version < "4.0" asyncua==1.1.6 ; python_version >= "3.11" and python_version < "4.0" +cffi==2.0.0 ; python_version >= "3.11" and python_version < "4.0" and platform_python_implementation != "PyPy" +cryptography==48.0.0 ; python_version >= "3.11" and python_version < "4.0" +msgpack==1.1.2 ; python_version >= "3.11" and python_version < "4.0" +paho-mqtt==2.1.0 ; python_version >= "3.11" and python_version < "4.0" +pycparser==3.0 ; python_version >= "3.11" and python_version < "4.0" and platform_python_implementation != "PyPy" and implementation_name != "PyPy" +pyopenssl==26.2.0 ; python_version >= "3.11" and python_version < "4.0" +python-dateutil==2.9.0.post0 ; python_version >= "3.11" and python_version < "4.0" +pytz==2026.2 ; python_version >= "3.11" and python_version < "4.0" +pyyaml==6.0.3 ; python_version >= "3.11" and python_version < "4.0" +six==1.17.0 ; python_version >= "3.11" and python_version < "4.0" +sortedcontainers==2.4.0 ; python_version >= "3.11" and python_version < "4.0" +typing-extensions==4.15.0 ; python_version >= "3.11" and python_version < "4.0" +unitsnet-py==0.1.154 ; python_version >= "3.11" and python_version < "4.0" +wait-for2==0.3.2 ; python_version >= "3.11" and python_version < "3.12" diff --git a/scripts/gen_requirements.sh b/scripts/gen_requirements.sh old mode 100644 new mode 100755 diff --git a/tests/builder/test_data_model_builder.py b/tests/builder/test_data_model_builder.py index 7f71c02..6efbf21 100644 --- a/tests/builder/test_data_model_builder.py +++ b/tests/builder/test_data_model_builder.py @@ -23,6 +23,10 @@ from machine_data_model.nodes.composite_method.composite_method_node import ( CompositeMethodNode, ) +from machine_data_model.nodes.connectors.mqtt import ( + MqttConnector, + MqttRemoteResourceSpec, +) from machine_data_model.nodes.connectors.opcua.opcua_connector import ( OpcuaConnector, OpcuaRemoteResourceSpec, @@ -670,3 +674,72 @@ def test_build_opcua_remote_resource_spec_full(self) -> None: assert node.node_id == "i=12345" assert node.namespace == "2" assert node.parent_node_id == "ns=6;s=Methods" + + def test_build_mqtt_connector_minimal(self) -> None: + yaml_content = """ + !!MqttConnector + name: "mqtt_broker" + """ + + node = yaml.safe_load(yaml_content) + + assert isinstance(node, MqttConnector) + assert node.name == "mqtt_broker" + assert node.ip == "127.0.0.1" + assert node.port == 1883 + assert node.qos == 0 + assert not node.retain + + def test_build_mqtt_connector_full(self) -> None: + yaml_content = """ + !!MqttConnector + name: "mqtt_broker" + ip: "192.168.1.10" + port: 1884 + username: "user" + password: "pass" + client_id: "machine-data-model" + topic_prefix: "machines/boiler-1" + keepalive: 30 + qos: 1 + retain: true + payload_codec: "json" + """ + + node = yaml.safe_load(yaml_content) + + assert isinstance(node, MqttConnector) + assert node.name == "mqtt_broker" + assert node.ip == "192.168.1.10" + assert node.port == 1884 + assert node.username == "user" + assert node.password == "pass" + assert node.client_id == "machine-data-model" + assert node.topic_prefix == "machines/boiler-1" + assert node.keepalive == 30 + assert node.qos == 1 + assert node.retain + assert node.payload_codec == "json" + + def test_build_mqtt_remote_resource_spec_full(self) -> None: + yaml_content = """ + !!MqttRemoteResourceSpec + remote_path: "legacy/topic" + topic: "plant/line-1/temp" + topic_prefix: "machines/boiler-1" + subscribe_topic: "plant/line-1/temp/state" + publish_topic: "plant/line-1/temp/set" + qos: 2 + retain: true + """ + + node = yaml.safe_load(yaml_content) + + assert isinstance(node, MqttRemoteResourceSpec) + assert node.remote_path == "legacy/topic" + assert node.topic == "plant/line-1/temp" + assert node.topic_prefix == "machines/boiler-1" + assert node.subscribe_topic == "plant/line-1/temp/state" + assert node.publish_topic == "plant/line-1/temp/set" + assert node.qos == 2 + assert node.retain diff --git a/tests/builder/test_data_model_dumper.py b/tests/builder/test_data_model_dumper.py index f2a2823..e4f3d12 100644 --- a/tests/builder/test_data_model_dumper.py +++ b/tests/builder/test_data_model_dumper.py @@ -1,8 +1,13 @@ import pytest +import yaml from machine_data_model.builder.data_model_builder import DataModelBuilder from machine_data_model.builder.data_model_dumper import DataModelDumper from machine_data_model.data_model import DataModel +from machine_data_model.nodes.connectors.mqtt import ( + MqttConnector, + MqttRemoteResourceSpec, +) from tests.test_data_model import get_template_data_model @@ -20,3 +25,47 @@ def test_dump(self, data_model: DataModel) -> None: new_data_model = builder.from_string(yaml) assert data_model.root == new_data_model.root + + def test_dump_mqtt_connector(self, data_model: DataModel) -> None: + connector = MqttConnector( + name="mqtt_broker", + ip="192.168.1.10", + port=1884, + client_id="machine-data-model", + topic_prefix="machines/boiler-1", + qos=1, + retain=True, + payload_codec="msgpack", + ) + + dumped = yaml.dump(connector) + loaded = yaml.safe_load(dumped) + + assert isinstance(loaded, MqttConnector) + assert loaded.name == connector.name + assert loaded.ip == connector.ip + assert loaded.port == connector.port + assert loaded.client_id == connector.client_id + assert loaded.topic_prefix == connector.topic_prefix + assert loaded.qos == connector.qos + assert loaded.retain == connector.retain + assert loaded.payload_codec == connector.payload_codec + + def test_dump_mqtt_remote_resource_spec( + self, data_model: DataModel + ) -> None: + spec = MqttRemoteResourceSpec( + subscribe_topic="plant/line-1/temp/state", + publish_topic="plant/line-1/temp/set", + qos=1, + retain=True, + ) + + dumped = yaml.dump(spec) + loaded = yaml.safe_load(dumped) + + assert isinstance(loaded, MqttRemoteResourceSpec) + assert loaded.subscribe_topic == spec.subscribe_topic + assert loaded.publish_topic == spec.publish_topic + assert loaded.qos == spec.qos + assert loaded.retain == spec.retain diff --git a/tests/nodes/connectors/mqtt/conftest.py b/tests/nodes/connectors/mqtt/conftest.py new file mode 100644 index 0000000..65a1754 --- /dev/null +++ b/tests/nodes/connectors/mqtt/conftest.py @@ -0,0 +1,78 @@ +import asyncio +from collections.abc import Generator +from pathlib import Path +import time +from typing import Any + +import aiomqtt +import docker +from docker.models.containers import Container +import pytest + +MQTT_MAX_ATTEMPTS = 50 +MQTT_CONNECTION_RETRY_DELAY = 0.1 + + +@pytest.fixture(scope="session") +def start_mqtt_test_broker( + tmp_path_factory: pytest.TempPathFactory, +) -> Generator[tuple[Container, int], Any, None]: + docker_client = docker.from_env() + config_path = _write_mosquitto_config( + tmp_path_factory.mktemp("mqtt_broker") + ) + container_guest_port = "1883/tcp" + container = docker_client.containers.run( + "eclipse-mosquitto:2", + detach=True, + auto_remove=True, + remove=True, + ports={container_guest_port: None}, + volumes={ + str(config_path): { + "bind": "/mosquitto/config/mosquitto.conf", + "mode": "ro", + } + }, + ) + + container.reload() + container_host_port = container.ports.get(container_guest_port)[0][ + "HostPort" + ] + assert str.isnumeric(container_host_port) + container_host_port = int(container_host_port) + + try: + _wait_for_broker(container, container_host_port) + yield container, container_host_port + finally: + container.stop() + + +def _write_mosquitto_config(config_dir: Path) -> Path: + config_path = config_dir / "mosquitto.conf" + config_path.write_text( + "listener 1883 0.0.0.0\n" "allow_anonymous true\n" "persistence false\n" + ) + return config_path + + +def _wait_for_broker(container: Container, port: int) -> None: + attempts = 0 + while attempts < MQTT_MAX_ATTEMPTS: + attempts += 1 + container.reload() + if container.status == "exited": + raise RuntimeError("MQTT test broker container exited unexpectedly") + try: + asyncio.run(_check_broker_connection(port)) + return + except Exception: + time.sleep(MQTT_CONNECTION_RETRY_DELAY) + raise TimeoutError("MQTT test broker did not become ready") + + +async def _check_broker_connection(port: int) -> None: + async with aiomqtt.Client(hostname="127.0.0.1", port=port): + pass diff --git a/tests/nodes/connectors/mqtt/test_mqtt_connector.py b/tests/nodes/connectors/mqtt/test_mqtt_connector.py new file mode 100644 index 0000000..60de5a2 --- /dev/null +++ b/tests/nodes/connectors/mqtt/test_mqtt_connector.py @@ -0,0 +1,498 @@ +import json +from types import SimpleNamespace +from typing import Any + +import msgpack +import pytest + +from machine_data_model.nodes.connectors.mqtt import ( + deserialize_mqtt_value, + deserialize_string_payload, + serialize_mqtt_value, + serialize_string_payload, +) +from machine_data_model.nodes.connectors.mqtt.mqtt_connector import ( + MqttConnector, +) +from machine_data_model.nodes.connectors.mqtt.mqtt_remote_resource_spec import ( + MqttRemoteResourceSpec, +) +from machine_data_model.nodes.connectors.remote_resource import RemoteResource +from machine_data_model.nodes.variable_node import ( + BooleanVariableNode, + StringVariableNode, +) + + +class FakeClient: + def __init__(self) -> None: + self.published: list[dict[str, Any]] = [] + self.subscribed: list[dict[str, Any]] = [] + + async def publish( + self, + topic: str, + payload: bytes, + qos: int, + retain: bool, + ) -> None: + self.published.append( + { + "topic": topic, + "payload": payload, + "qos": qos, + "retain": retain, + } + ) + + async def subscribe(self, topic: str, qos: int) -> None: + self.subscribed.append({"topic": topic, "qos": qos}) + + +class TestMqttConnector: + @pytest.mark.parametrize( + "value,expected_payload", + [ + ("hello", b"hello"), + (10, b"10"), + (12.5, b"12.5"), + (True, b"true"), + (False, b"false"), + ], + ) + def test_serialize_string_payload( + self, value: Any, expected_payload: bytes + ) -> None: + assert ( + serialize_string_payload(value, RemoteResource("plant/value")) + == expected_payload + ) + + @pytest.mark.parametrize( + "payload,expected_value", + [ + (b"hello", "hello"), + (b"true", True), + (b"FALSE", False), + (b"10", 10), + (b"-4", -4), + (b"12.5", 12.5), + (b"1e-3", 0.001), + ], + ) + def test_deserialize_string_payload( + self, payload: bytes, expected_value: Any + ) -> None: + assert ( + deserialize_string_payload(payload, RemoteResource("plant/value")) + == expected_value + ) + + def test_string_codec_uses_node_type_when_available(self) -> None: + string_node = StringVariableNode(name="Name", value="") + string_node.set_remote_path("plant/name") + boolean_node = BooleanVariableNode(name="Enabled", value=False) + boolean_node.set_remote_path("plant/enabled") + + assert ( + deserialize_string_payload( + b"10", RemoteResource.from_node(string_node) + ) + == "10" + ) + assert deserialize_string_payload( + b"true", RemoteResource.from_node(boolean_node) + ) + + def test_read_returns_last_cached_topic_value(self) -> None: + connector = MqttConnector(name="mqtt", topic_prefix="machines/b1") + try: + spec = MqttRemoteResourceSpec(topic="plant/temp") + connector._topic_payloads["plant/temp"] = b"42" + + assert ( + connector.read_node_value( + RemoteResource("/Objects/Temperature", spec) + ) + == 42 + ) + assert ( + connector.read_node_value( + RemoteResource("/Objects/Pressure", spec) + ) + == 42 + ) + finally: + connector.disconnect() + + def test_read_decodes_cached_payload_with_requesting_node_type( + self, + ) -> None: + connector = MqttConnector(name="mqtt") + try: + string_node = StringVariableNode(name="Name", value="") + string_node.set_remote_path("plant/name") + connector._topic_payloads["plant/name"] = b"10" + + assert ( + connector.read_node_value(RemoteResource.from_node(string_node)) + == "10" + ) + assert connector.read_node_value(RemoteResource("plant/name")) == 10 + finally: + connector.disconnect() + + def test_read_without_message_returns_none(self) -> None: + connector = MqttConnector(name="mqtt", topic_prefix="machines/b1") + try: + assert ( + connector.read_node_value( + RemoteResource("/Objects/Temperature") + ) + is None + ) + finally: + connector.disconnect() + + def test_disconnect_is_idempotent(self) -> None: + connector = MqttConnector(name="mqtt") + + assert connector.disconnect() + assert connector.disconnect() + + def test_write_publishes_encoded_value_to_publish_topic(self) -> None: + connector = MqttConnector( + name="mqtt", + topic_prefix="machines/b1", + qos=0, + retain=False, + ) + fake_client = FakeClient() + connector.client = fake_client + try: + spec = MqttRemoteResourceSpec( + subscribe_topic="plant/start/state", + publish_topic="plant/start/set", + qos=1, + retain=True, + ) + + assert connector.write_node_value( + RemoteResource("/Objects/Start", spec), True + ) + assert fake_client.published == [ + { + "topic": "plant/start/set", + "payload": b"true", + "qos": 1, + "retain": True, + } + ] + finally: + connector.disconnect() + + def test_write_uses_custom_payload_serializer(self) -> None: + def serializer( + value: Any, + resource: RemoteResource, + ) -> str: + return f"{resource.path}:{value}" + + connector = MqttConnector( + name="mqtt", + payload_serializer=serializer, + ) + fake_client = FakeClient() + connector.client = fake_client + try: + assert connector.write_node_value( + RemoteResource("plant/start"), True + ) + assert fake_client.published[0]["payload"] == b"plant/start:True" + finally: + connector.disconnect() + + def test_subscribe_and_message_dispatch(self) -> None: + connector = MqttConnector(name="mqtt", topic_prefix="machines/b1") + fake_client = FakeClient() + connector.client = fake_client + updates: list[tuple[Any, Any]] = [] + try: + subscription_id = connector.subscribe_to_node_changes( + RemoteResource("/Objects/Temperature"), + lambda value, other: updates.append((value, other)), + ) + + connector._handle_message( + SimpleNamespace( + topic=SimpleNamespace( + value="machines/b1/Objects/Temperature" + ), + payload=b"22.5", + qos=0, + retain=False, + ) + ) + + assert subscription_id == 1 + assert fake_client.subscribed == [ + {"topic": "machines/b1/Objects/Temperature", "qos": 0} + ] + assert updates[0][0] == 22.5 + assert updates[0][1].topic == "machines/b1/Objects/Temperature" + assert ( + connector.read_node_value( + RemoteResource("/Objects/Temperature") + ) + == 22.5 + ) + finally: + connector.disconnect() + + def test_message_dispatch_uses_custom_payload_deserializer(self) -> None: + def deserializer( + payload: bytes | bytearray, + resource: RemoteResource, + ) -> Any: + return f"{resource.path}:{payload.decode()}" + + connector = MqttConnector( + name="mqtt", + payload_deserializer=deserializer, + ) + fake_client = FakeClient() + connector.client = fake_client + updates: list[tuple[Any, Any]] = [] + try: + resource = RemoteResource("plant/temp") + connector.subscribe_to_node_changes( + resource, + lambda value, other: updates.append((value, other)), + ) + + connector._handle_message( + SimpleNamespace( + topic=SimpleNamespace(value="plant/temp"), + payload=b"22.5", + qos=0, + retain=False, + ) + ) + + assert updates[0][0] == "plant/temp:22.5" + assert connector.read_node_value(resource) == "plant/temp:22.5" + finally: + connector.disconnect() + + def test_message_caches_payload_before_deserialization_failure( + self, + ) -> None: + def deserializer( + _payload: bytes | bytearray, + _resource: RemoteResource, + ) -> Any: + raise ValueError("bad payload") + + connector = MqttConnector( + name="mqtt", + payload_deserializer=deserializer, + ) + try: + connector._handle_message( + SimpleNamespace( + topic=SimpleNamespace(value="plant/data"), + payload=b"bad", + qos=0, + retain=False, + ) + ) + + assert connector._topic_payloads["plant/data"] == b"bad" + finally: + connector.disconnect() + + def test_json_payload_codec(self) -> None: + connector = MqttConnector(name="mqtt", payload_codec="json") + fake_client = FakeClient() + connector.client = fake_client + updates: list[tuple[Any, Any]] = [] + try: + resource = RemoteResource("plant/data") + connector.subscribe_to_node_changes( + resource, + lambda value, other: updates.append((value, other)), + ) + assert connector.write_node_value(resource, {"count": 3}) + assert json.loads(fake_client.published[0]["payload"]) == { + "count": 3 + } + + connector._handle_message( + SimpleNamespace( + topic=SimpleNamespace(value="plant/data"), + payload=b'{"count":4}', + qos=0, + retain=False, + ) + ) + + assert updates[0][0] == {"count": 4} + assert connector.read_node_value(resource) == {"count": 4} + finally: + connector.disconnect() + + def test_msgpack_payload_codec(self) -> None: + connector = MqttConnector(name="mqtt", payload_codec="msgpack") + fake_client = FakeClient() + connector.client = fake_client + updates: list[tuple[Any, Any]] = [] + try: + resource = RemoteResource("plant/data") + connector.subscribe_to_node_changes( + resource, + lambda value, other: updates.append((value, other)), + ) + assert connector.write_node_value(resource, {"count": 3}) + assert msgpack.unpackb( + fake_client.published[0]["payload"], raw=False + ) == {"count": 3} + + connector._handle_message( + SimpleNamespace( + topic=SimpleNamespace(value="plant/data"), + payload=msgpack.packb({"count": 4}, use_bin_type=True), + qos=0, + retain=False, + ) + ) + + assert updates[0][0] == {"count": 4} + assert connector.read_node_value(resource) == {"count": 4} + finally: + connector.disconnect() + + def test_payload_value_helpers_use_selected_codec(self) -> None: + resource = RemoteResource("plant/data") + json_payload = serialize_mqtt_value({"count": 3}, resource, "json") + msgpack_payload = serialize_mqtt_value( + {"count": 3}, + resource, + "msgpack", + ) + + assert json_payload == b'{"count":3}' + assert deserialize_mqtt_value(json_payload, resource, "json") == { + "count": 3 + } + assert deserialize_mqtt_value( + msgpack_payload, + resource, + "msgpack", + ) == {"count": 3} + + def test_connector_value_helpers_use_custom_codec(self) -> None: + def serializer( + value: Any, + resource: RemoteResource, + ) -> bytes: + return f"{resource.path}:{value}".encode() + + def deserializer( + payload: bytes | bytearray, + resource: RemoteResource, + ) -> str: + return f"{resource.path}:{payload.decode()}" + + connector = MqttConnector( + name="mqtt", + payload_serializer=serializer, + payload_deserializer=deserializer, + ) + try: + resource = RemoteResource("plant/data") + + assert connector.serialize_value(7, resource) == b"plant/data:7" + assert connector.deserialize_value(b"7", resource) == "plant/data:7" + finally: + connector.disconnect() + + def test_payload_codec_property_updates_active_codec(self) -> None: + connector = MqttConnector(name="mqtt") + try: + resource = RemoteResource("plant/data") + + connector.payload_codec = "json" + + assert connector.payload_codec == "json" + assert connector.serialize_value({"count": 3}, resource) == ( + b'{"count":3}' + ) + assert connector.deserialize_value( + b'{"count":4}', + resource, + ) == {"count": 4} + finally: + connector.disconnect() + + def test_payload_serializer_property_updates_hot_path(self) -> None: + def serializer(value: Any, resource: RemoteResource) -> bytes: + return f"{resource.path}:{value}".encode() + + def deserializer( + payload: bytes | bytearray, + resource: RemoteResource, + ) -> str: + return f"{resource.path}:{payload.decode()}" + + connector = MqttConnector(name="mqtt") + try: + resource = RemoteResource("plant/data") + + connector.payload_serializer = serializer + connector.payload_deserializer = deserializer + + assert connector.serialize_value(7, resource) == b"plant/data:7" + assert connector.deserialize_value(b"8", resource) == ( + "plant/data:8" + ) + finally: + connector.disconnect() + + def test_resubscribes_same_topic_when_requested_qos_increases( + self, + ) -> None: + connector = MqttConnector(name="mqtt") + fake_client = FakeClient() + connector.client = fake_client + try: + connector.subscribe_to_node_changes( + RemoteResource( + "plant/temp", + MqttRemoteResourceSpec(topic="plant/temp", qos=0), + ), + lambda _value, _other: None, + ) + connector.subscribe_to_node_changes( + RemoteResource( + "plant/temp", + MqttRemoteResourceSpec(topic="plant/temp", qos=1), + ), + lambda _value, _other: None, + ) + + assert fake_client.subscribed == [ + {"topic": "plant/temp", "qos": 0}, + {"topic": "plant/temp", "qos": 1}, + ] + finally: + connector.disconnect() + + def test_call_node_as_method_raises_not_implemented(self) -> None: + connector = MqttConnector(name="mqtt") + try: + with pytest.raises(NotImplementedError, match="does not support"): + connector.call_node_as_method( + RemoteResource("/Objects/Method"), {} + ) + finally: + connector.disconnect() diff --git a/tests/nodes/connectors/mqtt/test_mqtt_connector_regressions.py b/tests/nodes/connectors/mqtt/test_mqtt_connector_regressions.py new file mode 100644 index 0000000..3e31903 --- /dev/null +++ b/tests/nodes/connectors/mqtt/test_mqtt_connector_regressions.py @@ -0,0 +1,335 @@ +"""Behavioural guards for the MQTT connector. + +These tests pin connector contracts that are easy to regress: + +* subscriptions -- unsubscribe tears down the broker subscription and the + per-topic state, and a topic's QoS tracks the running max of its live + subscribers (it ratchets back down, not just up). +* callback dispatch -- subscriber callbacks run off the event-loop thread, so a + callback may safely re-enter the connector (read/write) without deadlock. +* decoding -- each subscriber decodes a payload for its own node type, and + decoding stays stable even after the owning node is garbage-collected. +* configuration -- ``qos``/``retain`` stay validated for the object's lifetime, + and ``to_dict`` keeps ``*_env_var`` indirection. +""" + +from __future__ import annotations + +import asyncio +from concurrent.futures import TimeoutError as FutureTimeoutError +import gc +import threading +from types import SimpleNamespace +from typing import Any + +import pytest + +from machine_data_model.nodes.connectors.abstract_connector import ( + AbstractConnector, +) +from machine_data_model.nodes.connectors.mqtt.mqtt_connector import ( + MqttConnector, +) +from machine_data_model.nodes.connectors.mqtt.mqtt_remote_resource_spec import ( + MqttRemoteResourceSpec, +) +from machine_data_model.nodes.connectors.remote_resource import RemoteResource +from machine_data_model.nodes.variable_node import ( + NumericalVariableNode, + StringVariableNode, +) + + +class FakeClient: + """Records publish/subscribe/unsubscribe calls made by the connector.""" + + def __init__(self) -> None: + self.published: list[dict[str, Any]] = [] + self.subscribed: list[dict[str, Any]] = [] + self.unsubscribed: list[str] = [] + + async def publish( + self, + topic: str, + payload: bytes, + qos: int, + retain: bool, + ) -> None: + self.published.append( + {"topic": topic, "payload": payload, "qos": qos, "retain": retain} + ) + + async def subscribe(self, topic: str, qos: int) -> None: + self.subscribed.append({"topic": topic, "qos": qos}) + + async def unsubscribe(self, topic: str) -> None: + self.unsubscribed.append(topic) + + +def _message( + topic: str, payload: bytes, qos: int = 0, retain: bool = False +) -> SimpleNamespace: + """Build a minimal aiomqtt-shaped message for ``_handle_message``.""" + return SimpleNamespace( + topic=SimpleNamespace(value=topic), + payload=payload, + qos=qos, + retain=retain, + ) + + +async def _deliver( + connector: MqttConnector, topic: str, payload: bytes +) -> None: + """Deliver a message on the connector's event loop (the listener path).""" + connector._handle_message(_message(topic, payload)) + + +class TestUnsubscribe: + """Subscriptions can be torn down with the handle they hand out.""" + + def test_abstract_connector_declares_unsubscribe(self) -> None: + """``subscribe_to_node_changes`` promises an unsubscribe handle, so the + abstract contract must declare its counterpart.""" + assert hasattr(AbstractConnector, "unsubscribe_from_node_changes") + + def test_unsubscribe_removes_callback_and_state(self) -> None: + """Unsubscribing the last subscriber drops the broker subscription and + the per-topic state instead of leaking it for the connector's life.""" + connector = MqttConnector(name="mqtt") + fake_client = FakeClient() + connector.client = fake_client + try: + handle = connector.subscribe_to_node_changes( + RemoteResource( + "plant/temp", MqttRemoteResourceSpec(topic="plant/temp") + ), + lambda _value, _other: None, + ) + connector._handle_message(_message("plant/temp", b"21.5")) + + connector.unsubscribe_from_node_changes(handle) + + assert fake_client.unsubscribed == ["plant/temp"] + assert connector._topic_callbacks.get("plant/temp", {}) == {} + assert "plant/temp" not in connector._topic_qos + assert "plant/temp" not in connector._topic_payloads + finally: + connector.disconnect() + + +class TestCallbackDispatch: + """Callbacks run off the event loop, so they may re-enter the connector.""" + + def test_callback_does_not_run_on_event_loop_thread(self) -> None: + """Callbacks are dispatched off the event-loop thread, so a callback + that calls ``read_node_value``/``write_node_value`` cannot deadlock the + loop.""" + connector = MqttConnector(name="mqtt") + fake_client = FakeClient() + connector.client = fake_client + seen_thread: dict[str, Any] = {} + try: + connector.subscribe_to_node_changes( + RemoteResource( + "plant/temp", MqttRemoteResourceSpec(topic="plant/temp") + ), + lambda _value, _other: seen_thread.__setitem__( + "thread", threading.current_thread() + ), + ) + # Drive the message through the listener path on the loop thread. + connector._handle_task(_deliver(connector, "plant/temp", b"21.5")) + + assert seen_thread["thread"] is not connector._event_loop_thread + finally: + connector.disconnect() + + def test_reentrant_read_from_callback_does_not_deadlock(self) -> None: + """A callback may re-enter the connector -- the data model does this via + VariableNode notifications. Because delivery happens on the event loop + (the listener path), the callback must run off it; otherwise its inner + ``read_node_value`` submits a coroutine to the loop that is blocking on + the callback. + + Delivery is scheduled with ``run_coroutine_threadsafe`` and awaited with + a timeout so a regression surfaces as a bounded failure rather than a + hung suite; teardown is skipped on deadlock to avoid joining a wedged + daemon loop thread. + """ + connector = MqttConnector(name="mqtt") + fake_client = FakeClient() + connector.client = fake_client + result: dict[str, Any] = {} + deadlocked = False + + def callback(_value: Any, _other: Any) -> None: + result["read"] = connector.read_node_value( + RemoteResource( + "plant/temp", MqttRemoteResourceSpec(topic="plant/temp") + ) + ) + + try: + connector.subscribe_to_node_changes( + RemoteResource( + "plant/temp", MqttRemoteResourceSpec(topic="plant/temp") + ), + callback, + ) + + future = asyncio.run_coroutine_threadsafe( + _deliver(connector, "plant/temp", b"21.5"), + connector._event_loop, + ) + try: + future.result(timeout=5.0) + except FutureTimeoutError: + deadlocked = True + + assert not deadlocked, "reentrant read from the callback deadlocked" + assert result.get("read") == 21.5 + finally: + if not deadlocked: + connector.disconnect() + + +class TestQosRatchet: + """A topic's QoS follows the running max of its live subscribers.""" + + def test_qos_downgrades_when_high_qos_subscriber_leaves(self) -> None: + """Two subscribers raise the topic to QoS 1; when the QoS-1 subscriber + leaves, the topic is re-subscribed at the remaining max (QoS 0).""" + connector = MqttConnector(name="mqtt") + fake_client = FakeClient() + connector.client = fake_client + try: + connector.subscribe_to_node_changes( + RemoteResource( + "plant/temp", + MqttRemoteResourceSpec(topic="plant/temp", qos=0), + ), + lambda _v, _o: None, + ) + high = connector.subscribe_to_node_changes( + RemoteResource( + "plant/temp", + MqttRemoteResourceSpec(topic="plant/temp", qos=1), + ), + lambda _v, _o: None, + ) + + connector.unsubscribe_from_node_changes(high) + + assert connector._topic_qos["plant/temp"] == 0 + topic_subs = [ + s for s in fake_client.subscribed if s["topic"] == "plant/temp" + ] + assert topic_subs[-1]["qos"] == 0 + finally: + connector.disconnect() + + +class TestSharedTopicDecoding: + """Subscribers on a shared topic each decode for their own node type.""" + + def test_two_nodes_on_one_topic_each_get_their_own_type(self) -> None: + """A numeric and a string node share a topic; each callback receives the + value decoded for its node type, not a single shared decode.""" + connector = MqttConnector(name="mqtt") + fake_client = FakeClient() + connector.client = fake_client + + numeric = NumericalVariableNode(name="Count", value=0) + numeric.set_remote_path("plant/raw") + text = StringVariableNode(name="Label", value="") + text.set_remote_path("plant/raw") + + numeric_values: list[Any] = [] + string_values: list[Any] = [] + try: + connector.subscribe_to_node_changes( + RemoteResource.from_node(numeric), + lambda value, _o: numeric_values.append(value), + ) + connector.subscribe_to_node_changes( + RemoteResource.from_node(text), + lambda value, _o: string_values.append(value), + ) + + connector._handle_message(_message("plant/raw", b"10")) + + assert numeric_values == [10], "numeric node should decode int" + assert string_values == ["10"], "string node should keep the string" + finally: + connector.disconnect() + + +class TestStableDecoding: + """Decoding stays stable even after the owning node is collected.""" + + def test_dropped_node_ref_keeps_string_decoding(self) -> None: + """A StringVariableNode decodes payload "true" as the string "true"; + collecting the node must not flip that into the bool ``True``.""" + connector = MqttConnector(name="mqtt") + fake_client = FakeClient() + connector.client = fake_client + + node = StringVariableNode(name="Mode", value="") + node.set_remote_path("plant/mode") + resource = RemoteResource.from_node(node) + values: list[Any] = [] + try: + connector.subscribe_to_node_changes( + resource, lambda value, _o: values.append(value) + ) + + # Node alive: decodes as the string "true". + connector._handle_message(_message("plant/mode", b"true")) + assert values[-1] == "true" + + # Node collected: the weakref goes dead, decoding must not flip. + del node + gc.collect() + + connector._handle_message(_message("plant/mode", b"true")) + assert values[-1] == "true" + finally: + connector.disconnect() + + +class TestQosRetainValidation: + """qos/retain stay validated after construction, not only in __init__.""" + + def test_assigning_invalid_qos_is_rejected(self) -> None: + connector = MqttConnector(name="mqtt") + try: + with pytest.raises((ValueError, TypeError)): + connector.qos = 7 + finally: + connector.disconnect() + + def test_assigning_invalid_retain_is_rejected(self) -> None: + connector = MqttConnector(name="mqtt") + try: + with pytest.raises((ValueError, TypeError)): + connector.retain = "yes" # type: ignore[assignment] + finally: + connector.disconnect() + + +class TestToDictEnvVar: + """to_dict preserves env-var indirection for a faithful round-trip.""" + + def test_env_var_origin_survives_to_dict( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """``ip_env_var`` serialises the env-var reference, not the resolved + literal, so a dump/reload stays env-driven.""" + monkeypatch.setenv("MY_BROKER_HOST", "10.0.0.9") + connector = MqttConnector(name="mqtt", ip_env_var="MY_BROKER_HOST") + try: + data = connector.to_dict() + assert data.get("ip_env_var") == "MY_BROKER_HOST" + finally: + connector.disconnect() diff --git a/tests/nodes/connectors/mqtt/test_mqtt_data_model.py b/tests/nodes/connectors/mqtt/test_mqtt_data_model.py new file mode 100644 index 0000000..379bd70 --- /dev/null +++ b/tests/nodes/connectors/mqtt/test_mqtt_data_model.py @@ -0,0 +1,164 @@ +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import pytest + +from machine_data_model.data_model import DataModel +from machine_data_model.nodes.connectors.abstract_connector import ( + AbstractConnector, + SubscriptionArguments, +) +from machine_data_model.nodes.connectors.mqtt.mqtt_remote_resource_spec import ( + MqttRemoteResourceSpec, +) +from machine_data_model.nodes.connectors.remote_resource import RemoteResource +from machine_data_model.nodes.folder_node import FolderNode +from machine_data_model.nodes.variable_node import NumericalVariableNode + + +class NullRemoteConnector(AbstractConnector): + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self.disconnect_calls = 0 + self.read_resources: list[RemoteResource] = [] + self.write_resources: list[RemoteResource] = [] + self.subscription_resources: list[RemoteResource] = [] + + def connect(self) -> bool: + return True + + def disconnect(self) -> bool: + self.disconnect_calls += 1 + return True + + def _get_remote_resource(self, resource: RemoteResource) -> Any: + return resource.path + + def read_node_value(self, resource: RemoteResource) -> Any: + self.read_resources.append(resource) + return None + + def write_node_value( + self, + resource: RemoteResource, + value: Any, + ) -> bool: + self.write_resources.append(resource) + return True + + def call_node_as_method( + self, + resource: RemoteResource, + kwargs: dict[str, Any], + ) -> Any: + return {} + + def subscribe_to_node_changes( + self, + resource: RemoteResource, + callback: Callable[[Any, SubscriptionArguments], None], + ) -> int: + self.subscription_resources.append(resource) + return 1 + + def unsubscribe_from_node_changes(self, handle: int) -> bool: + return True + + +class FailingSubscriptionConnector(NullRemoteConnector): + def subscribe_to_node_changes( + self, + resource: RemoteResource, + callback: Callable[[Any, SubscriptionArguments], None], + ) -> int: + raise RuntimeError("subscription setup failed") + + +def test_remote_subscription_does_not_overwrite_default_with_none() -> None: + connector = NullRemoteConnector(name="remote") + temperature = NumericalVariableNode( + name="Temperature", + value=20.0, + remote_resource_spec=MqttRemoteResourceSpec(topic="plant/temp"), + ) + root = FolderNode( + name="Objects", + connector_name="remote", + children={"Temperature": temperature}, + ) + + DataModel(root=root, connectors=[connector]) + + assert temperature.value == 20.0 + + +def test_data_model_reuses_configured_remote_resource() -> None: + connector = NullRemoteConnector(name="remote") + temperature = NumericalVariableNode( + name="Temperature", + value=20.0, + remote_resource_spec=MqttRemoteResourceSpec(topic="plant/temp"), + ) + root = FolderNode( + name="Objects", + connector_name="remote", + children={"Temperature": temperature}, + ) + + DataModel(root=root, connectors=[connector]) + + configured_resource = temperature.remote_resource + temperature.read() + temperature.write(21.0) + + assert temperature.remote_resource is configured_resource + assert connector.subscription_resources[0] is configured_resource + assert connector.read_resources[-1] is configured_resource + assert connector.write_resources[-1] is configured_resource + + +def test_data_model_does_not_import_opcua_remote_resource_spec() -> None: + data_model_source = Path("machine_data_model/data_model.py").read_text() + + assert "OpcuaRemoteResourceSpec" not in data_model_source + + +def test_data_model_close_is_idempotent() -> None: + connector = NullRemoteConnector(name="remote") + root = FolderNode(name="Objects", connector_name="remote") + data_model = DataModel(root=root, connectors=[connector]) + + data_model.close() + data_model.close_connectors() + + assert connector.disconnect_calls == 1 + + +def test_data_model_context_manager_closes_connectors() -> None: + connector = NullRemoteConnector(name="remote") + root = FolderNode(name="Objects", connector_name="remote") + + with DataModel(root=root, connectors=[connector]): + assert connector.disconnect_calls == 0 + + assert connector.disconnect_calls == 1 + + +def test_data_model_closes_connectors_when_setup_fails() -> None: + connector = FailingSubscriptionConnector(name="remote") + temperature = NumericalVariableNode( + name="Temperature", + value=20.0, + remote_resource_spec=MqttRemoteResourceSpec(topic="plant/temp"), + ) + root = FolderNode( + name="Objects", + connector_name="remote", + children={"Temperature": temperature}, + ) + + with pytest.raises(RuntimeError, match="subscription setup failed"): + DataModel(root=root, connectors=[connector]) + + assert connector.disconnect_calls == 1 diff --git a/tests/nodes/connectors/mqtt/test_mqtt_integration.py b/tests/nodes/connectors/mqtt/test_mqtt_integration.py new file mode 100644 index 0000000..da4e091 --- /dev/null +++ b/tests/nodes/connectors/mqtt/test_mqtt_integration.py @@ -0,0 +1,160 @@ +import asyncio +from collections.abc import Callable +import time +from typing import Any +import uuid + +import aiomqtt +from docker.models.containers import Container + +from machine_data_model.builder.data_model_builder import DataModelBuilder +from machine_data_model.nodes.connectors.mqtt import MqttConnector +from machine_data_model.nodes.connectors.remote_resource import RemoteResource +from machine_data_model.nodes.variable_node import VariableNode + + +class TestMqttIntegration: + def test_subscribe_receives_external_publish( + self, + start_mqtt_test_broker: tuple[Container, int], + ) -> None: + _, broker_port = start_mqtt_test_broker + topic = _unique_topic("temperature") + connector = MqttConnector( + name="mqtt", + ip="127.0.0.1", + port=broker_port, + ) + updates: list[Any] = [] + + try: + assert connector.connect() + resource = RemoteResource(topic) + connector.subscribe_to_node_changes( + resource, + lambda value, _other: updates.append(value), + ) + _publish(broker_port, topic, "21.5") + + assert _wait_for(lambda: updates == [21.5]) + assert connector.read_node_value(resource) == 21.5 + finally: + connector.disconnect() + + def test_write_publishes_expected_message( + self, + start_mqtt_test_broker: tuple[Container, int], + ) -> None: + _, broker_port = start_mqtt_test_broker + topic = _unique_topic("setpoint") + connector = MqttConnector( + name="mqtt", + ip="127.0.0.1", + port=broker_port, + ) + + try: + assert connector.connect() + resource = RemoteResource(topic) + payload = asyncio.run( + _capture_message( + broker_port, + topic, + lambda: connector.write_node_value(resource, 42), + ) + ) + assert payload == b"42" + finally: + connector.disconnect() + + def test_data_model_subscribes_at_construction_time( + self, + start_mqtt_test_broker: tuple[Container, int], + ) -> None: + _, broker_port = start_mqtt_test_broker + topic_prefix = _unique_topic("model") + topic = f"{topic_prefix}/Objects/Temperature" + _publish(broker_port, topic, "25.5", retain=True) + data_model = DataModelBuilder().from_string( + _mqtt_data_model_yaml(broker_port, topic_prefix) + ) + node = data_model.get_node("Objects/Temperature") + assert isinstance(node, VariableNode) + + try: + assert _wait_for(lambda: node.read() == 25.5) + finally: + data_model.close_connectors() + _publish(broker_port, topic, "", retain=True) + + +def _unique_topic(suffix: str) -> str: + return f"machine-data-model/test/{uuid.uuid4()}/{suffix}" + + +def _publish( + port: int, + topic: str, + payload: str, + retain: bool = False, +) -> None: + asyncio.run(_async_publish(port, topic, payload, retain)) + + +async def _async_publish( + port: int, + topic: str, + payload: str, + retain: bool, +) -> None: + async with aiomqtt.Client(hostname="127.0.0.1", port=port) as client: + await client.publish(topic, payload=payload.encode(), retain=retain) + + +async def _capture_message( + port: int, + topic: str, + publish: Callable[[], bool], +) -> bytes: + async with aiomqtt.Client(hostname="127.0.0.1", port=port) as client: + await client.subscribe(topic) + await asyncio.to_thread(publish) + time_limit = time.monotonic() + 5 + while time.monotonic() < time_limit: + async for message in client.messages: + message_topic = getattr( + message.topic, + "value", + str(message.topic), + ) + if message_topic == topic: + return bytes(message.payload) + raise TimeoutError(f"Did not receive MQTT message for topic {topic}") + + +def _wait_for(predicate: Callable[[], bool], timeout: float = 5) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(0.05) + return False + + +def _mqtt_data_model_yaml(broker_port: int, topic_prefix: str) -> str: + return f""" +connectors: + - !!MqttConnector + name: "mqtt_broker" + ip: "127.0.0.1" + port: {broker_port} + topic_prefix: "{topic_prefix}" +root: + !!FolderNode + name: "Objects" + connector_name: "mqtt_broker" + children: + - !!NumericalVariableNode + name: "Temperature" + default_value: 20.0 +""" diff --git a/tests/nodes/connectors/mqtt/test_mqtt_remote_resource_spec.py b/tests/nodes/connectors/mqtt/test_mqtt_remote_resource_spec.py new file mode 100644 index 0000000..2f5349d --- /dev/null +++ b/tests/nodes/connectors/mqtt/test_mqtt_remote_resource_spec.py @@ -0,0 +1,72 @@ +import pytest + +from machine_data_model.data_model import DataModel +from machine_data_model.nodes.connectors.mqtt.mqtt_remote_resource_spec import ( + MqttRemoteResourceSpec, +) +from machine_data_model.nodes.folder_node import FolderNode +from machine_data_model.nodes.variable_node import NumericalVariableNode + + +class TestMqttRemoteResourceSpec: + def test_derives_topic_from_node_qualified_name_and_prefix(self) -> None: + root = FolderNode( + name="Objects", + remote_resource_spec=MqttRemoteResourceSpec( + topic_prefix="machines/boiler-1" + ), + children={ + "Temperature": NumericalVariableNode(name="Temperature"), + }, + ) + DataModel(root=root) + node = root["Temperature"] + + assert isinstance(node.remote_resource_spec, MqttRemoteResourceSpec) + assert node.remote_path == "machines/boiler-1/Objects/Temperature" + assert not hasattr(node.remote_resource_spec, "owner_node") + + def test_explicit_topic_overrides_derived_topic(self) -> None: + spec = MqttRemoteResourceSpec( + topic="plant/line-1/temperature", + topic_prefix="machines/boiler-1", + ) + + assert ( + spec.resolve_subscribe_topic("/Objects/Temperature") + == "plant/line-1/temperature" + ) + assert ( + spec.resolve_publish_topic("/Objects/Temperature") + == "plant/line-1/temperature" + ) + + def test_publish_topic_can_differ_from_subscribe_topic(self) -> None: + spec = MqttRemoteResourceSpec( + subscribe_topic="plant/line-1/start/state", + publish_topic="plant/line-1/start/set", + ) + + assert ( + spec.resolve_subscribe_topic("/Objects/Start") + == "plant/line-1/start/state" + ) + assert ( + spec.resolve_publish_topic("/Objects/Start") + == "plant/line-1/start/set" + ) + + def test_remote_path_is_subscription_topic_alias(self) -> None: + spec = MqttRemoteResourceSpec(remote_path="legacy/path") + + assert ( + spec.resolve_subscribe_topic("/Objects/Temperature") + == "legacy/path" + ) + + @pytest.mark.parametrize("topic", ["", "plant/+/temperature", "plant/#"]) + def test_rejects_empty_topics_and_wildcards(self, topic: str) -> None: + spec = MqttRemoteResourceSpec(topic=topic) + + with pytest.raises(ValueError): + spec.resolve_subscribe_topic("/Objects/Temperature") diff --git a/tests/nodes/connectors/opcua/conftest.py b/tests/nodes/connectors/opcua/conftest.py index 4ec7a2d..58bd909 100644 --- a/tests/nodes/connectors/opcua/conftest.py +++ b/tests/nodes/connectors/opcua/conftest.py @@ -1,5 +1,6 @@ import asyncio from collections.abc import Generator +from contextlib import suppress from pathlib import Path import time from typing import Any @@ -7,6 +8,10 @@ from asyncua.client.client import Client from asyncua.crypto.cert_gen import setup_self_signed_certificate from asyncua.crypto.security_policies import SecurityPolicyBasic256Sha256 +from asyncua.crypto.validator import ( + CertificateValidator, + CertificateValidatorOptions, +) from asyncua.sync import Server from cryptography.x509.oid import ExtendedKeyUsageOID import docker @@ -15,14 +20,19 @@ from tests.nodes.connectors.opcua import create_server +OPCUA_CLIENT_APP_URI = "urn:test-machine-data-model-client" +OPCUA_CLIENT_HOSTNAME = "test-machine-data-model-client" +OPCUA_MAX_ATTEMPTS = 50 +OPCUA_CONNECTION_RETRY_DELAY = 0.1 +OPCUA_CONTAINER_RETRY_DELAY = 0.5 + @pytest.fixture(scope="session") def start_opcua_test_server() -> Generator[tuple[Container, int], Any, None]: docker_client = docker.from_env() container_guest_port = "50000/tcp" container = docker_client.containers.run( - "mcr.microsoft.com/iotedge/opc-plc@sha256:" - "1fda0e687dee9bd86e1d40ad3e1e4a81d087777d59cab5abd62fdba9c3eefa9e", + "mcr.microsoft.com/iotedge/opc-plc:latest", "--pn=50000 --autoaccept --sph --sn=5 --sr=10 --st=uint " "--fn=5 --fr=1 --ft=uint --gn=5", auto_remove=True, @@ -42,55 +52,27 @@ def start_opcua_test_server() -> Generator[tuple[Container, int], Any, None]: ), "container_host_port must be numeric" container_host_port = int(container_host_port) - while container.status != "running": - time.sleep(0.5) + attempts = 0 + while attempts < OPCUA_MAX_ATTEMPTS and container.status != "running": + attempts += 1 + time.sleep(OPCUA_CONTAINER_RETRY_DELAY) container.reload() # if the container has exited, raise an error if container.status == "exited": raise RuntimeError( "OPC UA test server container exited unexpectedly." ) + if container.status != "running": + raise TimeoutError("OPC UA test server container did not start") async def check_opcua_connection() -> None: - # Setup self-signed certificate for the client - cert_dir = Path("tests/certificates/opcua") - cert_dir.mkdir(parents=True, exist_ok=True) - - private_key_path = cert_dir / "private.selfsigned.pem" - certificate_path = cert_dir / "cert.selfsigned.der" - - # Always generate fresh certificates for tests - await setup_self_signed_certificate( - private_key_path, - certificate_path, - "urn:test-machine-data-model-client", - "test-machine-data-model-client", - [ExtendedKeyUsageOID.CLIENT_AUTH], - { - "countryName": "CN", - "stateOrProvinceName": "AState", - "localityName": "Foo", - "organizationName": "Bar Ltd", - }, + private_key_path, certificate_path = await _setup_opcua_certificate() + await _wait_for_opcua_connection( + container_host_port, + private_key_path=private_key_path, + certificate_path=certificate_path, ) - client = Client(f"opc.tcp://localhost:{container_host_port}") - - connected = False - while not connected: - try: - await client.set_security( - SecurityPolicyBasic256Sha256, - certificate=str(certificate_path), - private_key=str(private_key_path), - server_certificate=None, - ) - connected = True - except Exception: - pass - await asyncio.sleep(0.1) - await client.disconnect() - asyncio.run(check_opcua_connection()) # time.sleep(2) # wait for server to be ready @@ -108,39 +90,8 @@ def start_custom_opcua_server() -> Generator[tuple[Server, int], Any, None]: server, port = create_server() async def check_opcua_connection() -> None: - # Setup self-signed certificate for the client - cert_dir = Path("tests/certificates/opcua") - cert_dir.mkdir(parents=True, exist_ok=True) - - private_key_path = cert_dir / "private.selfsigned.pem" - certificate_path = cert_dir / "cert.selfsigned.der" - - # Always generate fresh certificates for tests - await setup_self_signed_certificate( - private_key_path, - certificate_path, - "urn:test-machine-data-model-client", - "test-machine-data-model-client", - [ExtendedKeyUsageOID.CLIENT_AUTH], - { - "countryName": "CN", - "stateOrProvinceName": "AState", - "localityName": "Foo", - "organizationName": "Bar Ltd", - }, - ) - - client = Client(f"opc.tcp://localhost:{port}") - - connected = False - while not connected: - try: - await client.connect() - connected = True - except Exception: - pass - await asyncio.sleep(0.1) - await client.disconnect() + await _setup_opcua_certificate() + await _wait_for_opcua_connection(port) asyncio.run(check_opcua_connection()) @@ -149,3 +100,59 @@ async def check_opcua_connection() -> None: finally: server.stop() return None + + +async def _setup_opcua_certificate() -> tuple[Path, Path]: + # Setup self-signed certificate for the client + cert_dir = Path("tests/certificates/opcua") + cert_dir.mkdir(parents=True, exist_ok=True) + + private_key_path = cert_dir / "private.selfsigned.pem" + certificate_path = cert_dir / "cert.selfsigned.der" + + # Always generate fresh certificates for tests + await setup_self_signed_certificate( + private_key_path, + certificate_path, + OPCUA_CLIENT_APP_URI, + OPCUA_CLIENT_HOSTNAME, + [ExtendedKeyUsageOID.CLIENT_AUTH], + { + "countryName": "CN", + "stateOrProvinceName": "AState", + "localityName": "Foo", + "organizationName": "Bar Ltd", + }, + ) + return private_key_path, certificate_path + + +async def _wait_for_opcua_connection( + port: int, + *, + private_key_path: Path | None = None, + certificate_path: Path | None = None, +) -> None: + for _ in range(OPCUA_MAX_ATTEMPTS): + client = Client(f"opc.tcp://localhost:{port}") + client.application_uri = OPCUA_CLIENT_APP_URI + try: + if private_key_path is not None and certificate_path is not None: + await client.set_security( + SecurityPolicyBasic256Sha256, + certificate=str(certificate_path), + private_key=str(private_key_path), + server_certificate=None, + ) + client.certificate_validator = CertificateValidator( + CertificateValidatorOptions.EXT_VALIDATION + | CertificateValidatorOptions.PEER_SERVER + ) + await client.connect() + return + except Exception: + await asyncio.sleep(OPCUA_CONNECTION_RETRY_DELAY) + finally: + with suppress(Exception): + await client.disconnect() + raise TimeoutError("OPC UA test server did not become ready") diff --git a/tests/nodes/connectors/opcua/test_opcua_connector.py b/tests/nodes/connectors/opcua/test_opcua_connector.py index 593100a..a9ee492 100644 --- a/tests/nodes/connectors/opcua/test_opcua_connector.py +++ b/tests/nodes/connectors/opcua/test_opcua_connector.py @@ -9,6 +9,7 @@ OpcuaConnector, OpcuaRemoteResourceSpec, ) +from machine_data_model.nodes.connectors.remote_resource import RemoteResource from tests import gen_random_string @@ -73,22 +74,26 @@ def test_read_node_value( assert is_connected, "connector should connect successfully" with pytest.raises(ValueError, match="node doesn't exist"): - connector.read_node_value("non_existent_node") + connector.read_node_value(RemoteResource("non_existent_node")) temp_threshold_path = ( "Objects/6:ReferenceTest/" "6:Scalar/6:Scalar_Static/6:Scalar_Static_Boolean" ) - temp_threshold_value = connector.read_node_value(temp_threshold_path) + temp_threshold_value = connector.read_node_value( + RemoteResource(temp_threshold_path) + ) assert ( isinstance(temp_threshold_value, bool) and temp_threshold_value is not None ), "temp_threshold_value should not be None" remote_spec_result = connector.read_node_value( - "", - remote_resource_spec=OpcuaRemoteResourceSpec( - node_id="ns=6;s=Scalar_Static_Boolean", + RemoteResource( + "", + OpcuaRemoteResourceSpec( + node_id="ns=6;s=Scalar_Static_Boolean", + ), ), ) assert remote_spec_result == temp_threshold_value, ( @@ -113,21 +118,21 @@ def test_write_node_value( assert is_connected, "connector should connect successfully" with pytest.raises(ValueError, match="node doesn't exist"): - connector.write_node_value("non_existent_node", 123) + connector.write_node_value(RemoteResource("non_existent_node"), 123) temp_threshold_path = ( "Objects/6:ReferenceTest/6:Scalar/" "6:Scalar_Static/6:Scalar_Static_Boolean" ) prev_temp_threshold_value = connector.read_node_value( - temp_threshold_path + RemoteResource(temp_threshold_path) ) was_written = connector.write_node_value( - temp_threshold_path, not prev_temp_threshold_value + RemoteResource(temp_threshold_path), not prev_temp_threshold_value ) assert was_written, "this node should have been written" current_temp_threshold_value = connector.read_node_value( - temp_threshold_path + RemoteResource(temp_threshold_path) ) assert current_temp_threshold_value == ( @@ -138,15 +143,14 @@ def test_write_node_value( node_id="ns=6;s=Scalar_Static_Boolean" ) was_written_remote_spec = connector.write_node_value( - "", + RemoteResource("", remote_result_spec), not current_temp_threshold_value, - remote_resource_spec=remote_result_spec, ) assert ( was_written_remote_spec ), "this node should have been written using remote_resource_spec" new_temp_threshold_value = connector.read_node_value( - temp_threshold_path + RemoteResource(temp_threshold_path) ) assert new_temp_threshold_value == (not current_temp_threshold_value), ( "the new value should be the current value", @@ -171,23 +175,27 @@ def test_call_node_as_method( assert is_connected, "connector should connect successfully" with pytest.raises(ValueError, match="node doesn't exist"): - connector.call_node_as_method("non_existent_node", {}) + connector.call_node_as_method( + RemoteResource("non_existent_node"), {} + ) add_method_path = "Objects/6:ReferenceTest/6:Methods/6:Methods_Add" add_method_result = connector.call_node_as_method( - add_method_path, {"a": 2.0, "b": 3} + RemoteResource(add_method_path), {"a": 2.0, "b": 3} ) assert math.isclose( add_method_result, 5.0 ), "the result should be 5.0 after adding 2.0 and 3" remote_spec_result = connector.call_node_as_method( - "", - {"a": 4.0, "b": 6}, - remote_resource_spec=OpcuaRemoteResourceSpec( - parent_node_id="ns=6;s=Methods", - node_id="ns=6;s=Methods_Add", + RemoteResource( + "", + OpcuaRemoteResourceSpec( + parent_node_id="ns=6;s=Methods", + node_id="ns=6;s=Methods_Add", + ), ), + {"a": 4.0, "b": 6}, ) assert math.isclose(remote_spec_result, 10.0), ( "the result called with remote_resource_spec", @@ -210,7 +218,7 @@ def test_call_method_with_two_return_values( "Objects/2:Methods/2:callFreePalletToWithReservation" ) call_result = connector.call_node_as_method( - call_free_pallet_to_with_reservation_path, + RemoteResource(call_free_pallet_to_with_reservation_path), {"destination": 5, "reservationId": 1}, ) diff --git a/tests/nodes/connectors/test_abstract_async_connector.py b/tests/nodes/connectors/test_abstract_async_connector.py new file mode 100644 index 0000000..acb13ac --- /dev/null +++ b/tests/nodes/connectors/test_abstract_async_connector.py @@ -0,0 +1,71 @@ +from collections.abc import Callable +from typing import Any + +from machine_data_model.nodes.connectors.abstract_async_connector import ( + AbstractAsyncConnector, +) +from machine_data_model.nodes.connectors.abstract_connector import ( + SubscriptionArguments, +) +from machine_data_model.nodes.connectors.remote_resource import RemoteResource + + +class DummyAsyncConnector(AbstractAsyncConnector): + def __init__(self) -> None: + super().__init__(name="dummy") + self.connect_calls = 0 + self.disconnect_calls = 0 + + async def _async_connect(self) -> bool: + self.connect_calls += 1 + return True + + async def _async_disconnect(self) -> bool: + self.disconnect_calls += 1 + return True + + async def _async_get_remote_resource( + self, + resource: RemoteResource, + ) -> Any: + return resource.path + + async def _async_read_node_value( + self, + resource: RemoteResource, + ) -> Any: + return None + + async def _async_write_node_value( + self, + resource: RemoteResource, + value: Any, + ) -> bool: + return True + + async def _async_call_node_as_method( + self, + resource: RemoteResource, + kwargs: dict[str, Any], + ) -> Any: + return {} + + async def _async_subscribe_to_node_changes( + self, + resource: RemoteResource, + callback: Callable[[Any, SubscriptionArguments], None], + ) -> int: + return 1 + + +def test_owned_event_loop_is_recreated_after_disconnect() -> None: + connector = DummyAsyncConnector() + try: + assert connector.connect() + assert connector.disconnect() + assert connector.connect() + finally: + connector.disconnect() + + assert connector.connect_calls == 2 + assert connector.disconnect_calls == 2