import json from logging import Logger from pathlib import Path from typing import Callable from asyncua.crypto.security_policies import SecurityPolicyBasic256 from asyncua.sync import Client from sientia_do.notifications.models import NotificationLevel from ingestor.managers.data_manager import DataManager class OpcManager(): def __init__(self, name: str, url: str, data_manager: DataManager, logger: Logger, server_uri: str, cert_path: str = None, private_key_path: str = None, server_cert_path: str = None): self.url = url self.name = name self.server_uri = server_uri self.data_queue = {} self.logger = logger self.non_receive_count = 0 self.client = None self.cert_path = cert_path self.private_key_path = private_key_path self.server_cert_path = server_cert_path self.nodes = {} self.subscriptions = {} self.data_manager = data_manager def __str__(self): return f"OpcManager(name={self.name}, url={self.url}, server_uri={self.server_uri})\n" \ f"nodes={self.nodes}, subscriptions={self.subscriptions}" def initialize_from_config(self, server_config: dict, data_manager: DataManager, logger: Logger): """ Initializes the OpcManager instance using a server configuration dictionary. Args: server_config (dict): A dictionary containing server configuration details. data_manager (DataManager): An instance of DataManager for data handling. logger (Logger): Logger instance for logging information. """ self.__init__( server_config['name'], server_config['url'], data_manager, logger, server_config['server_uri'], server_config.get('cert_path'), server_config.get( 'private_key_path'), server_config.get('server_cert_path') ) self.config = server_config def set_security(self): """ Configures the security settings for the OPC UA client. This method sets up the security policy, certificates, and timeouts required for establishing a secure connection with the OPC UA server. Raises: ValueError: If either the certificate path or private key path is not provided. Attributes: cert_path (str): Path to the client's certificate file. private_key_path (str): Path to the client's private key file. server_cert_path (str, optional): Path to the server's certificate file. server_uri (str): The URI of the server to be used as the application URI. client (opcua.Client): The OPC UA client instance. logger (logging.Logger): Logger instance for logging information. Security Settings: - Security Policy: Basic256 - Secure Channel Timeout: 10,000,000 ms - Session Timeout: 10,000,000 ms """ if not all([self.cert_path, self.private_key_path]): raise ValueError( "Certificate and private key paths must be provided for secure connection.") cert = Path(self.cert_path) private_key = Path(self.private_key_path) server_cert = Path( self.server_cert_path) if self.server_cert_path else None self.client.application_uri = self.server_uri self.logger.info('Setting security...') self.client.set_security( SecurityPolicyBasic256, certificate=str(cert), private_key=str(private_key), server_certificate=str(server_cert) ) self.client.secure_channel_timeout = 10000000 self.client.session_timeout = 10000000 def connect(self): """ Establishes a connection to the OPC server. This method initializes the OPC client using the provided URL and sets up security if a certificate path is specified. It then attempts to connect to the server and logs the connection status. Raises: Exception: If the connection to the OPC server fails. """ self.client = Client(self.url) if self.cert_path: self.set_security() self.logger.info('Starting connection...') self.client.connect() def create_subscription(self, name: str, period: int = 500): """ Creates a subscription with the specified monitoring period. This method establishes a subscription to monitor data changes or events from the OPC UA server. If the client is not connected, an exception is raised. Args: period (int, optional): The monitoring period in milliseconds. Defaults to 500 ms. Raises: ValueError: If the client is not connected. Side Effects: - Sets the `self.period` attribute to the specified or default period. - Creates a subscription and assigns it to `self.subscription`. - Logs the creation of the subscription. """ if not self.client: raise ValueError("Client not connected. Call connect first.") p = period if period != None else 500 self.subscriptions[name] = self.client.create_subscription( p, self) self.logger.info('Subscription created.') def subscribe(self, subscription: str, nodes: dict, collect_period: int): """ Subscribes to a set of OPC UA nodes for data change notifications. This method adds the specified nodes to the subscription and configures their data collection rules based on the provided collection period and node-specific frequency. Args: nodes (dict): A dictionary where keys are node identifiers (e.g., node IDs or paths) and values are configurations for each node. Each configuration must include a 'frequency' key indicating the frequency of data collection in Hz. collect_period (int): The data collection period in seconds. Raises: ValueError: If the subscription has not been created by calling `create_subscription` prior to this method. """ if not self.subscriptions.get(subscription): raise ValueError( "Subscription not created. Call create_subscription first.") self.logger.info(f"Subscribing to {subscription}...") self.logger.info(f"Subscribing to nodes: {nodes}") self.addr_nodes = [self.client.get_node( n) for n in nodes if n not in self.nodes] self.nodes.update(nodes) self.collect_period = collect_period for node, config in self.nodes.items(): self.nodes[node]['cycle_rule'] = { 'cycle_increment': collect_period*1000/config['frequency'], 'cycle_count': 0 } self.subscriptions[subscription].subscribe_data_change(self.addr_nodes) def unsubscribe(self, subscription: str): if not self.subscriptions.get(subscription): self.logger.warning( f"Subscription {subscription} not found. Cannot unsubscribe.") return self.subscriptions[subscription].delete() del self.subscriptions[subscription] self.logger.info(f"Unsubscribed from {subscription}.") def __del__(self): self.disconnect() def disconnect(self): """ Disconnects from the OPC UA server. This method handles the disconnection process by deleting the subscription and disconnecting the client from the OPC UA server. It logs the disconnection process and handles any exceptions that may occur during cleanup. Raises: Exception: If an error occurs while deleting the subscription or disconnecting from the OPC UA server, it logs the error details. """ self.logger.warning('Disconnecting from OPC server') if self.client is None: self.logger.warning("Client already disconnected.") return try: [self.subscriptions[sub].delete() for sub in self.subscriptions] self.logger.warning("Deleted all subscriptions.") del self.client self.client = None self.logger.warning("Disconnected from OPC UA server.") except Exception as sub_error: self.logger.error(f"Failed to clean up subscription: {sub_error}") def datachange_notification(self, node, _val, data): """ Handles data change notifications for monitored OPC UA nodes. This method is triggered when a monitored node's value changes. It processes the notification, updates internal state, and publishes the data to the appropriate topics. Args: node (NodeId): The OPC UA node that triggered the data change notification. _val (Any): The new value of the node (unused in this implementation). data (DataChangeNotification): The data change notification object containing details about the change. Behavior: - Extracts the value and source timestamp from the monitored item. - Resets the cycle count for the node's cycle rule. - Resets the non-receive count. - Constructs a data dictionary containing the tag, tag name, timestamp, and value. - Publishes the data to all topics associated with the node. """ # get data value monitored_item = data.monitored_item value = monitored_item.Value.Value.Value # source_timestamp source_timestamp = monitored_item.Value.SourceTimestamp tag = str(node) self.nodes[tag]['cycle_rule']['cycle_count'] = 0 self.non_receive_count = 0 data = { 'tag': tag, 'name': self.nodes[str(node)]['tag_name'], 'timestamp': source_timestamp.strftime('%Y-%m-%d %H:%M:%S'), 'value': value } [self.data_manager.publish(e, data) for e in self.nodes[tag]['topics']] def check_cycles(self, removed_data: dict, node: str, config: dict, handle_listen_events: Callable[[str, str, NotificationLevel], None]): """ Checks the cycle count for a specific node and triggers a notification if the cycle count exceeds a threshold. Args: removed_data (dict): A dictionary containing data that has been removed. Used to check if the node is present. node (str): The identifier of the node being checked. config (dict): Configuration dictionary containing metadata such as the tag name. handle_listen_events (Callable[[str, str, NotificationLevel], None]): A callback function to handle notification events. It takes three arguments: - A message string describing the event. - A tag string identifying the event. - A NotificationLevel enum indicating the severity of the event. Behavior: - If the node is not in `removed_data`, the cycle count for the node is incremented. - If the cycle count reaches or exceeds 5, the `handle_listen_events` callback is invoked with a warning message, a tag, and a notification level. Notification Example: If the cycle count exceeds the threshold, a warning message is generated in the format: "{cycles} cycles without receive from {node}:{name}" where `cycles` is the current cycle count, `node` is the node identifier, and `name` is the tag name from the `config` dictionary. """ if node not in removed_data.keys(): self.nodes[node]['cycle_rule']['cycle_count'] += self.nodes[node]['cycle_rule']['cycle_increment'] if self.nodes[node]['cycle_rule']['cycle_count'] >= 5 and handle_listen_events: name = config['tag_name'] cycles = self.nodes[node]['cycle_rule']['cycle_count'] handle_listen_events( f'{cycles} cycles without receive from {node}:{name}', f'TAG_{node}:{name}_LISTENNING_STOPPED', NotificationLevel.WARNING ) def check_opc_listenning(self, handle_listen_events: Callable[[str, str, NotificationLevel], None]) -> None: """ Monitors the OPC connection and triggers events based on the number of cycles without receiving data from the OPC server. Args: handle_listen_events (Callable[[str, str, NotificationLevel], None]): A callback function to handle notification events. It takes three arguments: - A message string describing the event. - An event code string. - A NotificationLevel indicating the severity of the event. Behavior: - Increments the non-receive count each time the method is called. - If the non-receive count reaches 5, triggers a notification event indicating that the OPC server has stopped sending data. - If the non-receive count reaches 15, triggers a notification event indicating a retry to connect to the OPC server, disconnects the current session, and reinitializes the collector with the existing configuration. """ self.non_receive_count += 1 if self.non_receive_count >= 5 and handle_listen_events: handle_listen_events( f'{self.non_receive_count} cycles without receive from OPC {self.name}. Tags: {json.dumps(self.nodes)}', f'OPC_LISTENNING_STOPPED__{self.name}', NotificationLevel.ERROR) if self.non_receive_count >= 15 and handle_listen_events: handle_listen_events( f'Retrying to connect to server {self.name}', f'OPC_CONNECTION_RETRY__{self.name}', NotificationLevel.ERROR) self.disconnect() self.init_collector( self.nodes, self.collect_period, self.period)