def test_function_definitions(): point_definitions = PointDefinitions(point_definitions_path=POINT_DEFINITIONS_PATH) fdefs = FunctionDefinitions(point_definitions, function_definitions_path=FUNCTION_DEFINITIONS_PATH) fd = fdefs.function_for_id("curve") print(fd) pdef = point_definitions.get_point_named("DCHD.WinTms (out)") print(pdef) print(fdefs.step_definition_for_point(pdef))
def test_function_definitions(): point_definitions = PointDefinitions( point_definitions_path=POINT_DEFINITIONS_PATH) fdefs = FunctionDefinitions( point_definitions, function_definitions_path=FUNCTION_DEFINITIONS_PATH) fd = fdefs.function_for_id("curve") print(fd) pdef = point_definitions.get_point_named("DCHD.WinTms (out)") print(pdef) print(fdefs.step_definition_for_point(pdef))
class BaseDNP3Agent(Agent): """ DNP3Agent is a VOLTTRON agent that handles DNP3 outstation communications. DNP3Agent models a DNP3 outstation, communicating with a DNP3 master. For further information about this agent and DNP3 communications, please see the VOLTTRON DNP3 specification, located in VOLTTRON readthedocs under http://volttron.readthedocs.io/en/develop/specifications/dnp3_agent.html. This agent can be installed from a command-line shell as follows: export VOLTTRON_ROOT=<your volttron install directory> export DNP3_ROOT=$VOLTTRON_ROOT/services/core/DNP3Agent cd $VOLTTRON_ROOT python scripts/install-agent.py -s $DNP3_ROOT -i dnp3agent -c $DNP3_ROOT/dnp3agent.config -t dnp3agent -f """ def __init__(self, points=None, point_topic='', local_ip=None, port=None, outstation_config=None, local_point_definitions_path=None, **kwargs): """Initialize the DNP3 agent.""" super(BaseDNP3Agent, self).__init__(**kwargs) self.points = points self.point_topic = point_topic self.local_ip = local_ip self.port = port self.outstation_config = outstation_config self.default_config = { 'points': points, 'point_topic': point_topic, 'local_ip': local_ip, 'port': port, 'outstation_config': outstation_config, } self.application = None self.volttron_points = None self.point_definitions = None self._current_point_values = {} self._current_array = None self._local_point_definitions_path = local_point_definitions_path self.vip.config.set_default('config', self.default_config) self.vip.config.subscribe(self._configure, actions=['NEW', 'UPDATE'], pattern='config') def _configure(self, config_name, action, contents): """Initialize/Update the agent configuration.""" self._configure_parameters(contents) def load_point_definitions(self): """ Load and cache a dictionary of PointDefinitions from a json list. Index the dictionary by point_type and point index. """ _log.debug('Loading DNP3 point definitions.') try: self.point_definitions = PointDefinitions() self.point_definitions.load_points(self.points) except (AttributeError, TypeError) as err: if self._local_point_definitions_path: _log.warning( "Attempting to load point definitions from local path.") self.point_definitions = PointDefinitions( point_definitions_path=self._local_point_definitions_path) else: raise DNP3Exception( "Failed to load point definitions from config store: {}". format(err)) def start_outstation(self): """Start the DNP3Outstation instance, kicking off communication with the DNP3 Master.""" _log.info('Starting DNP3Outstation') self.publish_outstation_status('starting') self.application = DNP3Outstation(self.local_ip, self.port, self.outstation_config) self.application.start() self.publish_outstation_status('running') def stop_outstation(self): """Shutdown the DNP3Outstation application.""" _log.info('Stopping DNP3Outstation') self.publish_outstation_status('stopping') self.application.shutdown() self.publish_outstation_status('stopped') self.application = None def _configure_parameters(self, contents): """ Initialize/Update the DNP3 agent configuration. DNP3Agent configuration parameters (the MesaAgent subclass has some more): points: (string) A JSON structure of point definitions to be loaded. point_topic: (string) Message bus topic to use when publishing DNP3 point values. Default: mesa/point. outstation_status_topic: (string) Message bus topic to use when publishing outstation status. Default: mesa/outstation_status. local_ip: (string) Outstation's host address (DNS resolved). Default: 0.0.0.0. port: (integer) Outstation's port number - the port that the remote endpoint (Master) is listening on. Default: 20000. outstation_config: (dictionary) Outstation configuration parameters. All are optional. Parameters include: database_sizes: (integer) Size of each DNP3 database buffer. Default: 10000. event_buffers: (integer) Size of the database event buffers. Default: 10. allow_unsolicited: (boolean) Whether to allow unsolicited requests. Default: True. link_local_addr: (integer) Link layer local address. Default: 10. link_remote_addr: (integer) Link layer remote address. Default: 1. log_levels: List of bit field names (OR'd together) that filter what gets logged by DNP3. Default: [NORMAL]. Possible values: ALL, ALL_APP_COMMS, ALL_COMMS, NORMAL, NOTHING. threads_to_allocate: (integer) Threads to allocate in the manager's thread pool. Default: 1. """ config = self.default_config.copy() config.update(contents) self.points = config.get('points', []) self.point_topic = config.get('point_topic', DEFAULT_POINT_TOPIC) self.outstation_status_topic = config.get( 'outstation_status_topic', DEFAULT_OUTSTATION_STATUS_TOPIC) self.local_ip = config.get('local_ip', DEFAULT_LOCAL_IP) self.port = int(config.get('port', DEFAULT_PORT)) self.outstation_config = config.get('outstation_config', {}) _log.debug('DNP3Agent configuration parameters:') _log.debug('\tpoints type={}'.format(type(self.points))) _log.debug('\tpoint_topic={}'.format(self.point_topic)) _log.debug('\toutstation_status_topic={}'.format( self.outstation_status_topic)) _log.debug('\tlocal_ip={}'.format(self.local_ip)) _log.debug('\tport={}'.format(self.port)) _log.debug('\toutstation_config={}'.format(self.outstation_config)) self.load_point_definitions() DNP3Outstation.set_agent(self) # Stop outstation if DNP3 config has been changed if self.application and ( self.application.local_ip, self.application.port, self.application.outstation_config) != ( self.local_ip, self.port, self.outstation_config): self.stop_outstation() # Start outstation if the DNP3 application has not started if not self.application: self.start_outstation() return config @RPC.export def reset(self): """Reset the agent's internal state, emptying point value caches. Used during iterative testing.""" _log.info('Resetting agent state.') self._current_point_values = {} self._current_array = {} def get_current_point_value(self, data_type, index): """Return the most-recently-received PointValue for a given PointDefinition.""" if data_type not in self._current_point_values or index not in self._current_point_values[ data_type]: return None else: return self._current_point_values[data_type][index] def _set_point(self, point_name, value): """ (Internal) Set the value of a given input point (no debug trace). @param point_name: The VOLTTRON point name of a DNP3 PointDefinition. @param value: The value to set. The value's data type must match the one in the DNP3 PointDefinition. """ point_properties = self.volttron_points.get(point_name, {}) data_type = point_properties.get('data_type', None) index = point_properties.get('index', None) try: if data_type == DATA_TYPE_ANALOG_INPUT: wrapped_value = opendnp3.Analog(value) elif data_type == DATA_TYPE_BINARY_INPUT: wrapped_value = opendnp3.Binary(value) else: raise Exception( 'Unexpected data type for DNP3 point named {0}'.format( point_name)) DNP3Outstation.apply_update(wrapped_value, index) except Exception as e: raise DNP3Exception(e) def process_point_value(self, command_type, command, index, op_type): """ A point value was received from the Master. Process its payload. @param command_type: Either 'Select' or 'Operate'. @param command: A ControlRelayOutputBlock or else a wrapped data value (AnalogOutputInt16, etc.). @param index: DNP3 index of the payload's data definition. @param op_type: An OperateType, or None if command_type == 'Select'. @return: A CommandStatus value. """ try: point_value = self.point_definitions.point_value_for_command( command_type, command, index, op_type) if point_value is None: return opendnp3.CommandStatus.DOWNSTREAM_FAIL except Exception as ex: _log.error( 'No DNP3 PointDefinition for command with index {}'.format( index)) return opendnp3.CommandStatus.DOWNSTREAM_FAIL try: self._process_point_value(point_value) except Exception as ex: _log.error('Error processing DNP3 command: {}'.format(ex)) # Delete a cached point value (typically occurs only if an error is being handled). try: self._current_point_values.get(point_value.point_def.data_type, {}).pop(int(point_value.index), None) except Exception as err: _log.error( 'Error discarding cached value {}'.format(point_value)) return opendnp3.CommandStatus.DOWNSTREAM_FAIL return opendnp3.CommandStatus.SUCCESS def _process_point_value(self, point_value): _log.info('Received DNP3 {}'.format(point_value)) if point_value.command_type == 'Select': # Perform any needed validation now, then wait for the subsequent Operate command. return None else: # Update a dictionary that holds the most-recently-received value of each point. self._current_point_values.setdefault( point_value.point_def.data_type, {})[int(point_value.index)] = point_value return point_value def get_point_named(self, point_name): return self.point_definitions.get_point_named(point_name) def update_array_for_point(self, point_value): """A received point belongs to a PointArray. Update it.""" if point_value.point_def.is_array_head_point: self._current_array = PointArray(point_value.point_def) elif self._current_array is None: raise DNP3Exception( 'Array point received, but there is no current Array.') elif not self._current_array.contains_index(point_value.index): raise DNP3Exception( 'Received Array point outside of current Array.') self._current_array.add_point_value(point_value) def update_input_point(self, point_def, value): """ Update an input point. This may send its PointValue to the Master. :param point_def: A PointDefinition. :param value: A value to send (unwrapped simple data type, or else a list/array). """ if type(value) == list: # It's an array. Break it down into its constituent points, and apply each one separately. col_count = len(point_def.array_points) cols_by_name = { pt['name']: col for col, pt in enumerate(point_def.array_points) } for row_number, point_dict in enumerate(value): for pt_name, pt_val in point_dict.items(): pt_index = point_def.index + col_count * row_number + cols_by_name[ pt_name] array_point_def = self.point_definitions.get_point_named( point_def.name, index=pt_index) self._apply_point_update(array_point_def, pt_index, pt_val) else: self._apply_point_update(point_def, point_def.index, value) @staticmethod def _apply_point_update(point_def, point_index, value): """ Set an input point in the outstation database. This may send its PointValue to the Master. :param point_def: A PointDefinition. :param point_index: A numeric index for the point. :param value: A value to send (unwrapped, simple data type). """ data_type = point_def.data_type if data_type == DATA_TYPE_ANALOG_INPUT: wrapped_val = opendnp3.Analog(float(value)) if isinstance(value, bool) or not isinstance(value, numbers.Number): # Invalid data type raise DNP3Exception('Received {} value for {}.'.format( type(value), point_def)) elif data_type == DATA_TYPE_BINARY_INPUT: wrapped_val = opendnp3.Binary(value) if not isinstance(value, bool): # Invalid data type raise DNP3Exception('Received {} value for {}.'.format( type(value), point_def)) else: # The agent supports only DNP3's Analog and Binary point types at this time. raise DNP3Exception('Unsupported point type {}'.format(data_type)) if wrapped_val is not None: DNP3Outstation.apply_update(wrapped_val, point_index) _log.debug('Sent DNP3 point {}, value={}'.format( point_def, wrapped_val.value)) def publish_point_value(self, point_value): """Publish a PointValue as it is received from the DNP3 Master.""" _log.info('Publishing DNP3 {}'.format(point_value)) msg = { point_value.name: (point_value.unwrapped_value() if point_value else None) } if point_value.point_def.action == PUBLISH_AND_RESPOND: msg.update({'response': point_value.point_def.response}) self.publish_data(self.point_topic, msg) def publish_outstation_status(self, outstation_status): """Publish outstation status.""" _log.info('Publishing outstation status: {}'.format(outstation_status)) self.publish_data(self.outstation_status_topic, outstation_status) def publish_data(self, topic, msg): """Publish a payload to the message bus.""" try: self.vip.pubsub.publish(peer='pubsub', topic=topic, headers={ headers.TIMESTAMP: utils.format_timestamp( utils.get_aware_utc_now()) }, message=msg) except Exception as err: if os.environ.get('UNITTEST', False): _log.debug( 'Disregarding publish_data exception during unit test') else: raise DNP3Exception( 'Error publishing topic {}, message {}: {}'.format( topic, msg, err)) def dnp3_point_name(self, point_name): """ Return a point's DNP3 point name, mapped from its VOLTTRON point name if necessary. If VOLTTRON point names were configured (by the DNP device driver), map them to DNP3 point names. """ dnp3_point_name = self.volttron_points.get( point_name, '') if self.volttron_points else point_name if not dnp3_point_name: raise DNP3Exception( 'No configured point for {}'.format(point_name)) return dnp3_point_name @RPC.export def get_point(self, point_name): """ Look up the most-recently-received value for a given output point. @param point_name: The point name of a DNP3 PointDefinition. @return: The (unwrapped) value of a received point. """ _log.info('Getting point value for {}'.format(point_name)) try: point_name = self.dnp3_point_name(point_name) point_def = self.point_definitions.get_point_named(point_name) point_value = self.get_current_point_value(point_def.data_type, point_def.index) return point_value.unwrapped_value() if point_value else None except Exception as e: raise DNP3Exception(e) @RPC.export def get_point_by_index(self, data_type, index): """ Look up the most-recently-received value for a given point. @param data_type: The data_type of a DNP3 point. @param index: The index of a DNP3 point. @return: The (unwrapped) value of a received point. """ _log.info('Getting point value for data_type {} and index {}'.format( data_type, index)) try: point_value = self.get_current_point_value(data_type, index) return point_value.unwrapped_value() if point_value else None except Exception as e: raise DNP3Exception(e) @RPC.export def get_points(self, point_list): """ Look up the most-recently-received value of each configured output point. @param point_list: A list of point names. @return: A dictionary of point values, indexed by their point names. """ _log.info( 'Getting values for the following points: {}'.format(point_list)) try: return {name: self.get_point(name) for name in point_list} except Exception as e: raise DNP3Exception(e) @RPC.export def get_configured_points(self): """ Look up the most-recently-received value of each configured point. @return: A dictionary of point values, indexed by their point names. """ if self.volttron_points is None: raise DNP3Exception('DNP3 points have not been configured') _log.info('Getting all DNP3 configured point values') try: return { name: self.get_point(name) for name in self.volttron_points } except Exception as e: raise DNP3Exception(e) @RPC.export def set_point(self, point_name, value): """ Set the value of a given input point. @param point_name: The point name of a DNP3 PointDefinition. @param value: The value to set. The value's data type must match the one in the DNP3 PointDefinition. """ _log.info('Setting DNP3 {} point value = {}'.format(point_name, value)) try: self.update_input_point( self.get_point_named(self.dnp3_point_name(point_name)), value) except Exception as e: raise DNP3Exception(e) @RPC.export def set_points(self, point_dict): """ Set point values for a dictionary of points. @param point_dict: A dictionary of {point_name: value} for a list of DNP3 points to set. """ _log.info('Setting DNP3 point values: {}'.format(point_dict)) try: for point_name, value in point_dict.items(): self.update_input_point( self.get_point_named(self.dnp3_point_name(point_name)), value) except Exception as e: raise DNP3Exception(e) @RPC.export def config_points(self, point_map): """ For each of the agent's points, map its VOLTTRON point name to its DNP3 group and index. @param point_map: A dictionary that maps a point's VOLTTRON point name to its DNP3 group and index. """ _log.info('Configuring DNP3 points: {}'.format(point_map)) self.volttron_points = point_map @RPC.export def get_point_definitions(self, point_name_list): """ For each DNP3 point name in point_name_list, return a dictionary with each of the point definitions. The returned dictionary looks like this: { "point_name1": { "property1": "property1_value", "property2": "property2_value", ... }, "point_name2": { "property1": "property1_value", "property2": "property2_value", ... } } If a definition cannot be found for a point name, it is omitted from the returned dictionary. :param point_name_list: A list of point names. :return: A dictionary of point definitions. """ _log.info('Fetching a list of DNP3 point definitions for {}'.format( point_name_list)) try: response = {} for name in point_name_list: point_def = self.point_definitions.get_point_named( self.dnp3_point_name(name)) if point_def is not None: response[name] = point_def.as_json() return response except Exception as e: raise DNP3Exception(e)